smodusermc commited on
Commit
26d48c6
·
verified ·
1 Parent(s): 66fb4d9

Update static/script.js

Browse files
Files changed (1) hide show
  1. static/script.js +260 -601
static/script.js CHANGED
@@ -1,25 +1,17 @@
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,
@@ -31,72 +23,48 @@
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'),
@@ -104,21 +72,18 @@
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);
@@ -128,84 +93,65 @@
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) {
@@ -216,647 +162,392 @@
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) {
819
- const div = document.createElement('div');
820
- div.textContent = text;
821
- return div.innerHTML;
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;
@@ -868,41 +559,9 @@
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
  })();
 
 
 
 
 
 
1
  (function() {
2
  'use strict';
3
 
 
 
 
4
  const State = {
5
  token: localStorage.getItem('token'),
6
  user: null,
7
  ws: null,
8
+ wsState: 'disconnected',
9
+ messages: new Map(),
10
+ messageOrder: [],
11
+ cursor: null,
12
  hasMore: true,
13
+ replyTo: null,
14
+ pendingFile: null,
15
  typingUsers: new Map(),
16
  onlineUsers: new Map(),
17
  isAtBottom: true,
 
23
  }
24
  };
25
 
 
 
 
26
  const DOM = {
 
27
  authScreen: document.getElementById('auth-screen'),
28
  chatScreen: document.getElementById('chat-screen'),
 
 
29
  loginForm: document.getElementById('login-form'),
30
  signupForm: document.getElementById('signup-form'),
31
  authTabs: document.querySelectorAll('.auth-tab'),
32
  authError: document.getElementById('auth-error'),
33
  authErrorText: document.getElementById('auth-error-text'),
34
  authLoading: document.getElementById('auth-loading'),
 
 
35
  headerDisplayName: document.getElementById('header-display-name'),
36
  headerAvatar: document.getElementById('header-avatar'),
37
  headerAvatarPlaceholder: document.getElementById('header-avatar-placeholder'),
38
  headerStatus: document.getElementById('header-status'),
39
  onlineBadge: document.getElementById('online-badge'),
 
 
40
  connectionStatus: document.getElementById('connection-status'),
41
  connectionText: document.getElementById('connection-text'),
 
 
42
  messagesList: document.getElementById('messages-list'),
43
  messagesScroll: document.getElementById('messages-scroll'),
44
  emptyState: document.getElementById('empty-state'),
45
  loadOlderBtn: document.getElementById('load-older-btn'),
46
  scrollBottomBtn: document.getElementById('scroll-bottom-btn'),
 
 
47
  typingIndicator: document.getElementById('typing-indicator'),
48
  typingText: document.getElementById('typing-text'),
 
 
49
  replyPreview: document.getElementById('reply-preview'),
50
  replyUser: document.getElementById('reply-user'),
51
  replyText: document.getElementById('reply-text'),
 
 
52
  composeForm: document.getElementById('compose-form'),
53
  messageInput: document.getElementById('message-input'),
54
  sendBtn: document.getElementById('send-btn'),
55
  attachBtn: document.getElementById('attach-btn'),
56
  emojiBtn: document.getElementById('emoji-btn'),
57
  fileInput: document.getElementById('file-input'),
 
 
58
  uploadPreview: document.getElementById('upload-preview'),
59
  uploadFilename: document.getElementById('upload-filename'),
60
  cancelUpload: document.getElementById('cancel-upload'),
61
  uploadProgress: document.getElementById('upload-progress'),
62
  progressFill: document.getElementById('progress-fill'),
63
  progressText: document.getElementById('progress-text'),
 
 
64
  settingsModal: document.getElementById('settings-modal'),
65
  onlineModal: document.getElementById('online-modal'),
66
  emojiPicker: document.getElementById('emoji-picker'),
67
  emojiGrid: document.getElementById('emoji-grid'),
 
 
68
  settingsDisplayName: document.getElementById('settings-display-name'),
69
  settingsUsername: document.getElementById('settings-username'),
70
  settingsAvatarPreview: document.getElementById('settings-avatar-preview'),
 
72
  avatarInput: document.getElementById('avatar-input'),
73
  themeOptions: document.querySelectorAll('.theme-option'),
74
  customThemeSettings: document.getElementById('custom-theme-settings'),
75
+ onlineBtn: document.getElementById('online-btn'),
76
+ onlineUsersList: document.getElementById('online-users-list'),
77
+ onlineTotal: document.getElementById('online-total'),
78
  toastContainer: document.getElementById('toast-container')
79
  };
80
 
81
+ const API = window.location.origin;
82
 
 
 
 
83
  function applyTheme(theme) {
84
  document.documentElement.setAttribute('data-theme', theme);
85
  State.theme = theme;
86
  localStorage.setItem('theme', theme);
 
87
  if (theme === 'custom') {
88
  document.documentElement.style.setProperty('--bubble-self', State.customColors.self);
89
  document.documentElement.style.setProperty('--bubble-other', State.customColors.other);
 
93
  }
94
 
95
  function isLightColor(hex) {
96
+ const r = parseInt(hex.slice(1,3), 16), g = parseInt(hex.slice(3,5), 16), b = parseInt(hex.slice(5,7), 16);
 
 
97
  return (0.299 * r + 0.587 * g + 0.114 * b) > 150;
98
  }
99
 
100
  applyTheme(State.theme);
101
 
102
+ function showToast(msg, type = 'info') {
 
 
 
103
  const toast = document.createElement('div');
104
  toast.className = `toast toast-${type}`;
105
+ toast.innerHTML = `<i class="fas fa-${type === 'success' ? 'check-circle' : type === 'error' ? 'exclamation-circle' : 'info-circle'}"></i><span>${msg}</span>`;
 
 
 
 
106
  DOM.toastContainer.appendChild(toast);
107
  setTimeout(() => toast.remove(), 4000);
108
  }
109
 
110
+ // Auth
111
+ DOM.authTabs.forEach(tab => tab.addEventListener('click', () => {
112
+ const target = tab.dataset.tab;
113
+ DOM.authTabs.forEach(t => t.classList.remove('active'));
114
+ tab.classList.add('active');
115
+ DOM.loginForm.classList.toggle('active', target === 'login');
116
+ DOM.signupForm.classList.toggle('active', target === 'signup');
117
+ DOM.authError.style.display = 'none';
118
+ }));
 
 
 
 
119
 
120
+ DOM.loginForm.addEventListener('submit', async e => {
121
  e.preventDefault();
122
+ const u = document.getElementById('login-username').value.trim();
123
+ const p = document.getElementById('login-password').value;
124
+ await login(u, p);
125
  });
126
 
127
+ DOM.signupForm.addEventListener('submit', async e => {
128
  e.preventDefault();
129
+ const u = document.getElementById('signup-username').value.trim();
130
+ const d = document.getElementById('signup-display').value.trim() || u;
131
+ const p = document.getElementById('signup-password').value;
132
+ await signup(u, p, d);
133
  });
134
 
135
  async function login(username, password) {
136
+ DOM.authLoading.classList.remove('hidden');
137
  try {
138
+ const res = await fetch(`${API}/api/auth/login?username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`, { method: 'POST' });
139
  const data = await res.json();
140
  if (!res.ok) throw new Error(data.detail || 'Login failed');
141
  handleAuthSuccess(data);
142
+ } catch (err) { showAuthError(err.message); }
143
+ finally { DOM.authLoading.classList.add('hidden'); }
 
 
 
144
  }
145
 
146
  async function signup(username, password, display) {
147
+ DOM.authLoading.classList.remove('hidden');
148
  try {
149
+ const res = await fetch(`${API}/api/auth/signup?username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}&display_name=${encodeURIComponent(display)}`, { method: 'POST' });
150
  const data = await res.json();
151
  if (!res.ok) throw new Error(data.detail || 'Signup failed');
152
  handleAuthSuccess(data);
153
+ } catch (err) { showAuthError(err.message); }
154
+ finally { DOM.authLoading.classList.add('hidden'); }
 
 
 
155
  }
156
 
157
  function handleAuthSuccess(data) {
 
162
  connectWebSocket();
163
  }
164
 
 
 
 
 
 
 
 
 
 
165
  function showAuthError(msg) {
166
  DOM.authErrorText.textContent = msg;
167
  DOM.authError.style.display = 'flex';
168
  }
169
 
170
+ // WS
 
 
171
  function connectWebSocket() {
172
+ if (!State.token || (State.ws && State.ws.readyState === WebSocket.OPEN)) return;
 
 
173
  State.wsState = 'connecting';
174
  updateConnectionUI();
 
175
  const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
176
+ State.ws = new WebSocket(`${proto}//${location.host}/ws?token=${encodeURIComponent(State.token)}`);
 
 
 
177
  State.ws.onopen = () => {
178
  State.wsState = 'connected';
179
  updateConnectionUI();
 
 
180
  };
181
+ State.ws.onmessage = e => handleWSMessage(JSON.parse(e.data));
 
 
182
  State.ws.onclose = () => {
183
+ if (State.wsState === 'connected') { State.wsState = 'reconnecting'; setTimeout(connectWebSocket, 3000); }
184
+ else State.wsState = 'disconnected';
 
 
 
 
 
 
 
 
 
 
185
  updateConnectionUI();
186
  };
187
+ State.ws.onerror = () => { State.wsState = 'disconnected'; updateConnectionUI(); };
188
  }
189
 
190
  function updateConnectionUI() {
191
+ const s = DOM.connectionStatus;
192
+ s.className = 'connection-status';
193
+ if (State.wsState === 'connected') { s.classList.add('connected'); DOM.connectionText.textContent = 'Connected'; }
194
+ else if (State.wsState === 'reconnecting') { s.classList.add('reconnecting'); DOM.connectionText.textContent = 'Reconnecting...'; }
195
+ else { s.classList.add('disconnected'); DOM.connectionText.textContent = 'Disconnected'; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  }
197
 
 
 
 
198
  function handleWSMessage(msg) {
199
  switch (msg.type) {
200
  case 'connection_established':
201
+ State.user = { id: msg.user_id, username: msg.username, display_name: msg.display_name, avatar_path: msg.avatar_path };
 
 
 
 
 
202
  msg.online_users.forEach(u => State.onlineUsers.set(u.id, u));
203
  updateOnlineBadge();
204
+ updateHeader();
205
+ loadMessages();
206
  break;
 
207
  case 'new_message':
208
  handleNewMessage(msg.message);
209
  break;
 
210
  case 'messages_loaded':
211
+ msg.messages.forEach(m => { if (!State.messages.has(m.id)) { State.messages.set(m.id, m); State.messageOrder.unshift(m.id); } });
212
  State.hasMore = msg.has_more;
213
+ State.cursor = msg.next_cursor;
214
+ renderAllMessages();
215
  DOM.loadOlderBtn.classList.toggle('hidden', !msg.has_more);
216
  break;
 
217
  case 'user_status':
218
+ if (msg.status === 'online') State.onlineUsers.set(msg.user_id, msg);
219
+ else State.onlineUsers.delete(msg.user_id);
 
 
 
220
  updateOnlineBadge();
221
  break;
 
222
  case 'typing_indicator':
223
+ if (msg.is_typing) State.typingUsers.set(msg.user_id, msg);
224
+ else State.typingUsers.delete(msg.user_id);
 
 
 
225
  updateTypingIndicator();
226
  break;
 
227
  case 'message_edited':
228
+ if (State.messages.has(msg.message_id)) {
229
+ State.messages.get(msg.message_id).content = msg.content;
230
+ State.messages.get(msg.message_id).is_edited = true;
231
+ updateMessageBubble(msg.message_id);
232
+ }
233
  break;
 
234
  case 'message_deleted':
235
+ if (State.messages.has(msg.message_id)) {
236
+ State.messages.get(msg.message_id).is_deleted = true;
237
+ updateMessageBubble(msg.message_id);
238
+ }
239
  break;
 
240
  case 'message_read':
241
+ if (State.messages.has(msg.message_id) && State.messages.get(msg.message_id).sender_id === State.user.id) {
242
+ State.messages.get(msg.message_id).status = 'read';
243
+ updateMessageBubble(msg.message_id);
244
+ }
245
  break;
 
246
  case 'online_users':
247
  State.onlineUsers.clear();
248
  msg.users.forEach(u => State.onlineUsers.set(u.id, u));
249
  updateOnlineBadge();
250
  break;
 
251
  case 'error':
252
  showToast(msg.message, 'error');
 
253
  }
254
  }
255
 
256
+ function handleNewMessage(msg) {
257
+ if (State.messages.has(msg.id)) return;
258
+ State.messages.set(msg.id, msg);
259
+ State.messageOrder.push(msg.id);
260
+ renderMessage(msg);
261
+ if (State.isAtBottom) scrollToBottom();
262
+ if (document.visibilityState === 'visible') sendMarkRead(msg.id);
 
 
 
 
 
 
 
 
 
263
  if (document.visibilityState !== 'visible' && Notification.permission === 'granted') {
264
+ new Notification(`${msg.sender_display_name}`, { body: msg.content || 'Sent a file' });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  }
266
  }
267
 
268
+ function loadMessages() {
269
+ if (State.ws?.readyState === WebSocket.OPEN) State.ws.send(JSON.stringify({ type: 'load_messages', limit: 50 }));
 
 
 
 
270
  }
271
 
272
+ function sendMarkRead(messageId) {
273
+ if (State.ws?.readyState === WebSocket.OPEN) State.ws.send(JSON.stringify({ type: 'mark_read', up_to_message_id: messageId }));
 
 
 
 
274
  }
275
 
276
+ // Render
 
 
277
  function renderMessage(msg) {
278
+ if (DOM.messagesList.querySelector(`[data-id="${msg.id}"]`)) return;
279
+ const wrap = document.createElement('div');
280
+ wrap.className = `message-wrapper ${msg.sender_id === State.user.id ? 'self' : 'other'}`;
281
+ wrap.dataset.id = msg.id;
282
+ wrap.innerHTML = buildMessageHTML(msg);
283
+ DOM.messagesList.appendChild(wrap);
 
284
  }
285
 
286
  function buildMessageHTML(msg) {
287
+ if (msg.is_deleted) return '<div class="message-bubble deleted">This message was deleted</div>';
 
 
 
288
  const isSelf = msg.sender_id === State.user?.id;
289
  let html = '';
 
 
290
  if (msg.reply_to_id && State.messages.has(msg.reply_to_id)) {
291
+ const r = State.messages.get(msg.reply_to_id);
292
+ html += `<div class="reply-indicator"><i class="fas fa-reply"></i><span>${r.sender_username}: ${truncate(r.content, 50)}</span></div>`;
 
 
 
293
  }
 
 
294
  html += `<div class="message-bubble">`;
295
  html += `<div class="message-text">${escapeHtml(msg.content)}</div>`;
296
+ if (msg.file_path) html += buildAttachment(msg);
297
+ html += `<div class="message-meta"><span class="message-time">${formatTime(msg.timestamp_ms)}</span>`;
298
+ if (msg.is_edited) html += '<span class="edited-label">edited</span>';
 
 
 
 
 
 
 
299
  if (isSelf) {
300
+ const icon = msg.status === 'read' ? '<i class="fas fa-check-double"></i>' : '<i class="fas fa-check"></i>';
301
+ html += `<span class="message-status ${msg.status==='read'?'read':''}">${icon}</span>`;
302
  }
303
+ html += `</div><div class="message-actions">`;
304
+ html += `<button onclick="InfinityChat.replyTo(${msg.id})"><i class="fas fa-reply"></i></button>`;
 
 
 
305
  if (isSelf) {
306
+ html += `<button onclick="InfinityChat.editMessage(${msg.id})"><i class="fas fa-pen"></i></button>`;
307
+ html += `<button onclick="InfinityChat.deleteMessage(${msg.id})"><i class="fas fa-trash"></i></button>`;
308
  }
309
+ html += `</div></div>`;
 
 
310
  return html;
311
  }
312
 
313
+ function buildAttachment(msg) {
314
+ const url = `${API}/api/download/${encodeURIComponent(msg.file_path)}?token=${encodeURIComponent(State.token)}`;
315
+ if (msg.file_type?.startsWith('image/')) return `<div class="attachment"><img src="${url}" alt="${msg.file_name}" loading="lazy"></div>`;
316
+ if (msg.file_type?.startsWith('video/')) return `<div class="attachment"><video controls preload="metadata"><source src="${url}" type="${msg.file_type}"></video></div>`;
317
+ if (msg.file_type?.startsWith('audio/')) return `<div class="attachment"><audio controls src="${url}"></audio></div>`;
318
+ return `<div class="attachment-file" onclick="window.open('${url}')"><i class="fas fa-file"></i><div class="file-info"><div class="file-name">${msg.file_name||'File'}</div><div class="file-size">${formatFileSize(msg.file_size)}</div></div></div>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  }
320
 
321
+ function updateMessageBubble(id) {
322
+ const el = DOM.messagesList.querySelector(`[data-id="${id}"]`);
323
+ if (!el) return;
324
+ const msg = State.messages.get(id);
325
+ if (msg) el.innerHTML = buildMessageHTML(msg);
 
326
  }
327
 
328
  function renderAllMessages() {
329
+ DOM.messagesList.innerHTML = '';
330
+ if (State.messageOrder.length === 0) DOM.emptyState.style.display = 'flex';
331
+ else {
332
+ DOM.emptyState.style.display = 'none';
333
+ State.messageOrder.forEach(id => { if (State.messages.has(id)) renderMessage(State.messages.get(id)); });
 
 
 
 
334
  }
335
  }
336
 
337
+ function scrollToBottom() {
338
+ DOM.messagesScroll.scrollTop = DOM.messagesScroll.scrollHeight;
339
+ }
340
+
341
+ DOM.messagesScroll.addEventListener('scroll', () => {
342
+ const { scrollTop, scrollHeight, clientHeight } = DOM.messagesScroll;
343
+ State.isAtBottom = (scrollHeight - scrollTop - clientHeight) < 60;
344
+ DOM.scrollBottomBtn.classList.toggle('hidden', State.isAtBottom);
345
+ });
346
+ DOM.scrollBottomBtn.addEventListener('click', scrollToBottom);
347
+
348
+ // Compose
349
+ DOM.composeForm.addEventListener('submit', e => { e.preventDefault(); sendMessage(); });
350
+ DOM.messageInput.addEventListener('input', () => {
351
+ const has = DOM.messageInput.value.trim().length > 0 || State.pendingFile;
352
+ DOM.sendBtn.disabled = !has;
353
+ if (has) sendTyping(true);
354
+ autoResize();
355
  });
356
 
357
  function sendMessage() {
358
  const content = DOM.messageInput.value.trim();
359
  if (!content && !State.pendingFile) return;
360
+ if (State.ws?.readyState !== WebSocket.OPEN) return;
361
+ State.ws.send(JSON.stringify({
 
362
  type: 'send_message',
363
+ content,
364
+ reply_to_id: State.replyTo?.id || null,
365
+ client_id: generateId(),
366
+ file_path: State.pendingFile?.file_path,
367
+ file_type: State.pendingFile?.file_type,
368
+ file_name: State.pendingFile?.file_name,
369
+ file_size: State.pendingFile?.file_size
370
+ }));
 
 
 
 
 
371
  DOM.messageInput.value = '';
372
  clearReply();
373
  clearPendingFile();
374
+ autoResize();
375
  sendTyping(false);
376
  }
377
 
 
 
 
 
 
 
 
 
 
 
 
 
378
  function sendTyping(isTyping) {
379
+ if (State.ws?.readyState !== WebSocket.OPEN) return;
380
+ clearTimeout(window._typingTimer);
381
  if (isTyping) {
382
+ State.ws.send(JSON.stringify({ type: 'typing', is_typing: true }));
383
+ window._typingTimer = setTimeout(() => sendTyping(false), 2000);
 
 
 
384
  } else {
385
+ State.ws.send(JSON.stringify({ type: 'typing', is_typing: false }));
 
 
 
386
  }
387
  }
388
 
389
  function updateTypingIndicator() {
390
  const users = Array.from(State.typingUsers.values()).filter(u => u.user_id !== State.user.id);
391
  DOM.typingIndicator.classList.toggle('hidden', users.length === 0);
392
+ DOM.typingText.textContent = users.length ? users.map(u => u.display_name).join(', ') + ' typing...' : '';
 
 
393
  }
394
 
395
+ function clearReply() { State.replyTo = null; DOM.replyPreview.classList.add('hidden'); }
396
+ function clearPendingFile() { State.pendingFile = null; DOM.uploadPreview.classList.add('hidden'); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
397
 
398
+ window.InfinityChat = {
399
+ replyTo: (id) => {
400
+ const msg = State.messages.get(id);
401
+ if (!msg) return;
402
+ State.replyTo = msg;
403
+ DOM.replyUser.textContent = msg.sender_username;
404
+ DOM.replyText.textContent = truncate(msg.content, 50);
405
+ DOM.replyPreview.classList.remove('hidden');
406
+ DOM.messageInput.focus();
407
+ },
408
+ editMessage: (id) => {
409
+ const msg = State.messages.get(id);
410
+ if (!msg) return;
411
+ const newContent = prompt('Edit:', msg.content);
412
+ if (newContent !== null && newContent.trim() !== '') {
413
+ State.ws.send(JSON.stringify({ type: 'edit_message', message_id: id, content: newContent }));
414
+ }
415
+ },
416
+ deleteMessage: (id) => {
417
+ if (confirm('Delete permanently?')) {
418
+ State.ws.send(JSON.stringify({ type: 'delete_message', message_id: id }));
419
+ }
420
  }
421
+ };
422
 
423
+ // File upload
 
 
424
  DOM.attachBtn.addEventListener('click', () => DOM.fileInput.click());
425
+ DOM.fileInput.addEventListener('change', async e => {
 
 
426
  const file = e.target.files[0];
427
  if (!file) return;
428
+ await uploadChunked(file);
429
+ e.target.value = '';
430
+ });
 
 
 
 
 
431
 
432
+ async function uploadChunked(file) {
433
+ const CHUNK = 5*1024*1024, total = Math.ceil(file.size/CHUNK), uploadId = generateId();
434
  DOM.uploadProgress.classList.remove('hidden');
435
+ for (let i=0; i<total; i++) {
436
+ const start = i*CHUNK, end = Math.min(start+CHUNK, file.size);
437
+ const blob = file.slice(start,end);
438
+ const fd = new FormData(); fd.append('file', blob);
439
+ const res = await fetch(`${API}/api/upload/chunk?chunk_index=${i}&total_chunks=${total}&file_name=${encodeURIComponent(file.name)}&file_type=${encodeURIComponent(file.type||'application/octet-stream')}&file_size=${file.size}&upload_id=${uploadId}`, { method:'POST', body:fd, headers:{'X-Auth-Token':State.token} });
440
+ if (!res.ok) { alert('Upload failed'); break; }
 
 
 
 
 
 
 
 
 
 
 
 
441
  const data = await res.json();
442
+ updateProgress(((i+1)/total)*100);
443
  if (data.status === 'complete') {
444
+ State.pendingFile = { file_path: data.file_path, file_type: data.file_type, file_name: data.file_name, file_size: file_size };
445
+ DOM.uploadPreview.classList.remove('hidden');
446
+ DOM.uploadFilename.textContent = data.file_name;
447
+ DOM.sendBtn.disabled = false;
 
 
 
448
  DOM.uploadProgress.classList.add('hidden');
449
  return;
450
  }
 
451
  }
452
  DOM.uploadProgress.classList.add('hidden');
453
  }
454
 
455
+ function updateProgress(pct) {
456
+ DOM.progressFill.style.width = `${pct}%`;
457
+ DOM.progressText.textContent = `${Math.round(pct)}%`;
 
 
 
 
 
 
 
 
 
 
 
 
458
  }
459
 
460
+ DOM.cancelUpload.addEventListener('click', clearPendingFile);
461
 
462
+ // Online modal
463
+ DOM.onlineBtn.addEventListener('click', () => {
464
+ renderOnlineUsers();
465
+ DOM.onlineModal.classList.remove('hidden');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
466
  });
467
+ document.querySelectorAll('[data-close="online-modal"]').forEach(b => b.addEventListener('click', () => DOM.onlineModal.classList.add('hidden')));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
468
 
469
  function updateOnlineBadge() {
470
  DOM.onlineBadge.textContent = State.onlineUsers.size;
471
  }
472
 
473
+ function renderOnlineUsers() {
474
+ DOM.onlineUsersList.innerHTML = '';
475
+ if (State.onlineUsers.size === 0) {
476
+ DOM.onlineUsersList.innerHTML = '<div class="loading-state"><span>No users online</span></div>';
477
+ DOM.onlineTotal.textContent = '0 users online';
478
+ return;
479
+ }
480
+ State.onlineUsers.forEach(u => {
481
+ const div = document.createElement('div');
482
+ div.className = 'user-item';
483
+ div.innerHTML = `<div class="user-avatar"><i class="fas fa-user-circle"></i></div><div class="user-info"><div class="user-name">${u.display_name || u.username}</div><div class="user-status-text">online</div></div>`;
484
+ DOM.onlineUsersList.appendChild(div);
485
+ });
486
+ DOM.onlineTotal.textContent = `${State.onlineUsers.size} users online`;
487
+ }
488
 
489
+ // Settings
490
+ document.getElementById('settings-btn').addEventListener('click', () => {
491
  DOM.settingsDisplayName.value = State.user?.display_name || '';
492
  DOM.settingsUsername.value = State.user?.username || '';
493
  if (State.user?.avatar_path) {
494
+ DOM.settingsAvatarImg.src = `${API}/api/download/${encodeURIComponent(State.user.avatar_path)}?token=${encodeURIComponent(State.token)}`;
495
  DOM.settingsAvatarImg.classList.remove('hidden');
496
  DOM.settingsAvatarPreview.classList.add('hidden');
497
  } else {
498
  DOM.settingsAvatarImg.classList.add('hidden');
499
  DOM.settingsAvatarPreview.classList.remove('hidden');
500
  }
501
+ DOM.themeOptions.forEach(o => o.classList.toggle('active', o.dataset.theme === State.theme));
 
502
  DOM.customThemeSettings.classList.toggle('hidden', State.theme !== 'custom');
503
  DOM.settingsModal.classList.remove('hidden');
504
+ });
505
+ document.querySelectorAll('[data-close="settings-modal"]').forEach(b => b.addEventListener('click', () => DOM.settingsModal.classList.add('hidden')));
506
+ document.getElementById('save-settings').addEventListener('click', async () => {
507
  const newName = DOM.settingsDisplayName.value.trim();
508
  if (newName && newName !== State.user?.display_name) {
509
+ await fetch(`${API}/api/profile?display_name=${encodeURIComponent(newName)}`, { method:'PATCH', headers:{'X-Auth-Token':State.token} });
510
+ State.user.display_name = newName;
511
+ updateHeader();
512
+ showToast('Profile updated', 'success');
 
 
 
 
513
  }
514
  DOM.settingsModal.classList.add('hidden');
 
 
 
 
 
 
 
 
 
 
 
 
515
  });
516
 
517
+ DOM.themeOptions.forEach(opt => opt.addEventListener('click', () => {
518
+ applyTheme(opt.dataset.theme);
519
+ DOM.themeOptions.forEach(o => o.classList.remove('active'));
520
+ opt.classList.add('active');
521
+ DOM.customThemeSettings.classList.toggle('hidden', opt.dataset.theme !== 'custom');
522
+ }));
523
 
524
+ function updateHeader() {
525
+ if (State.user) DOM.headerDisplayName.textContent = State.user.display_name;
 
 
 
 
 
 
 
526
  }
527
 
528
+ // Utility
529
+ function generateId() { return Date.now().toString(36) + Math.random().toString(36).substr(2,9); }
530
+ function truncate(s,n) { return s && s.length > n ? s.substr(0,n-1)+'…' : s || ''; }
531
+ function escapeHtml(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
532
  function formatTime(ms) {
533
+ const d = new Date(ms), n = new Date();
534
+ if (d.toDateString() === n.toDateString()) return d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
535
+ return d.toLocaleDateString([], {month:'short',day:'numeric'}) + ' ' + d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
 
 
 
 
536
  }
537
+ function formatFileSize(b) {
538
+ if (!b) return '0 B';
539
+ const k=1024, sizes=['B','KB','MB','GB'], i=Math.floor(Math.log(b)/Math.log(k));
540
+ return parseFloat((b/Math.pow(k,i)).toFixed(1))+' '+sizes[i];
 
 
 
541
  }
542
+ function autoResize() {
 
543
  DOM.messageInput.style.height = 'auto';
544
  DOM.messageInput.style.height = Math.min(DOM.messageInput.scrollHeight, 120) + 'px';
545
  }
546
 
547
+ // Init
 
 
 
 
 
 
 
 
 
548
  async function init() {
549
  if (State.token) {
550
+ const res = await fetch(`${API}/api/auth/verify`, { headers: {'X-Auth-Token': State.token} });
551
  if (res.ok) {
552
  const data = await res.json();
553
  State.user = data.user;
 
559
  showAuth();
560
  }
561
 
562
+ function showAuth() { DOM.authScreen.classList.add('active'); DOM.chatScreen.classList.remove('active'); }
563
+ function showChat() { DOM.authScreen.classList.remove('active'); DOM.chatScreen.classList.add('active'); updateHeader(); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
564
 
565
+ if (window.Notification && Notification.permission === 'default') Notification.requestPermission();
566
  init();
567
  })();