smodusermc commited on
Commit
d20b876
·
verified ·
1 Parent(s): 35f7916

Update static/script.js

Browse files
Files changed (1) hide show
  1. static/script.js +374 -177
static/script.js CHANGED
@@ -31,68 +31,47 @@
31
  // DOM Cache
32
  // =============================================
33
  const DOM = {
34
- // Screens
35
  authScreen: document.getElementById('auth-screen'),
36
  chatScreen: document.getElementById('chat-screen'),
37
-
38
- // Auth
39
  loginForm: document.getElementById('login-form'),
40
  signupForm: document.getElementById('signup-form'),
41
  authTabs: document.querySelectorAll('.auth-tab'),
42
  authError: document.getElementById('auth-error'),
43
  authErrorText: document.getElementById('auth-error-text'),
44
  authLoading: document.getElementById('auth-loading'),
45
-
46
- // Header
47
  headerDisplayName: document.getElementById('header-display-name'),
48
  headerAvatar: document.getElementById('header-avatar'),
49
  headerAvatarPlaceholder: document.getElementById('header-avatar-placeholder'),
50
  headerStatus: document.getElementById('header-status'),
51
  onlineBadge: document.getElementById('online-badge'),
52
-
53
- // Connection
54
  connectionStatus: document.getElementById('connection-status'),
55
  connectionText: document.getElementById('connection-text'),
56
-
57
- // Messages
58
  messagesList: document.getElementById('messages-list'),
59
  messagesScroll: document.getElementById('messages-scroll'),
60
  emptyState: document.getElementById('empty-state'),
61
  loadOlderBtn: document.getElementById('load-older-btn'),
62
  scrollBottomBtn: document.getElementById('scroll-bottom-btn'),
63
-
64
- // Typing
65
  typingIndicator: document.getElementById('typing-indicator'),
66
  typingText: document.getElementById('typing-text'),
67
-
68
- // Reply
69
  replyPreview: document.getElementById('reply-preview'),
70
  replyUser: document.getElementById('reply-user'),
71
  replyText: document.getElementById('reply-text'),
72
-
73
- // Compose
74
  composeForm: document.getElementById('compose-form'),
75
  messageInput: document.getElementById('message-input'),
76
  sendBtn: document.getElementById('send-btn'),
77
  attachBtn: document.getElementById('attach-btn'),
78
  emojiBtn: document.getElementById('emoji-btn'),
79
  fileInput: document.getElementById('file-input'),
80
-
81
- // Upload
82
  uploadPreview: document.getElementById('upload-preview'),
83
  uploadFilename: document.getElementById('upload-filename'),
84
  cancelUpload: document.getElementById('cancel-upload'),
85
  uploadProgress: document.getElementById('upload-progress'),
86
  progressFill: document.getElementById('progress-fill'),
87
  progressText: document.getElementById('progress-text'),
88
-
89
- // Modals
90
  settingsModal: document.getElementById('settings-modal'),
91
  onlineModal: document.getElementById('online-modal'),
92
  emojiPicker: document.getElementById('emoji-picker'),
93
  emojiGrid: document.getElementById('emoji-grid'),
94
-
95
- // Settings
96
  settingsDisplayName: document.getElementById('settings-display-name'),
97
  settingsUsername: document.getElementById('settings-username'),
98
  settingsAvatarPreview: document.getElementById('settings-avatar-preview'),
@@ -107,18 +86,22 @@
107
  selfColorHex: document.getElementById('self-color-hex'),
108
  otherColorHex: document.getElementById('other-color-hex'),
109
  textColorHex: document.getElementById('text-color-hex'),
110
-
111
- // Online users modal
112
  onlineBtn: document.getElementById('online-btn'),
113
  onlineUsersList: document.getElementById('online-users-list'),
114
  onlineTotal: document.getElementById('online-total'),
115
-
116
- // Toast
117
  toastContainer: document.getElementById('toast-container')
118
  };
119
 
120
  const API = window.location.origin;
121
 
 
 
 
 
 
 
 
 
122
  // =============================================
123
  // Theme
124
  // =============================================
@@ -135,11 +118,10 @@
135
  document.documentElement.style.setProperty('--bubble-other-text', textColor);
136
  const isLight = (c) => {
137
  const r = parseInt(c.slice(1,3),16), g = parseInt(c.slice(3,5),16), b = parseInt(c.slice(5,7),16);
138
- return (0.299 * r + 0.587 * g + 0.114 * b) > 150;
139
  };
140
  document.documentElement.style.setProperty('--bubble-self-text', isLight(selfColor) ? '#111827' : '#FFFFFF');
141
  } else {
142
- // Remove inline custom properties to let CSS variables take over
143
  document.documentElement.style.removeProperty('--bubble-self');
144
  document.documentElement.style.removeProperty('--bubble-other');
145
  document.documentElement.style.removeProperty('--bubble-other-text');
@@ -149,17 +131,25 @@
149
 
150
  applyTheme(State.theme);
151
 
 
 
 
 
 
 
 
 
 
 
 
152
  // =============================================
153
  // Toast Notifications
154
  // =============================================
155
  function showToast(message, type = 'info') {
156
  const toast = document.createElement('div');
157
  toast.className = `toast toast-${type}`;
158
- let icon = 'info-circle';
159
- if (type === 'success') icon = 'check-circle';
160
- else if (type === 'error') icon = 'exclamation-circle';
161
- else if (type === 'warning') icon = 'exclamation-triangle';
162
- toast.innerHTML = `<i class="fas fa-${icon}"></i><span>${message}</span>`;
163
  DOM.toastContainer.appendChild(toast);
164
  setTimeout(() => toast.remove(), 4000);
165
  }
@@ -263,8 +253,7 @@
263
 
264
  State.ws.onmessage = (event) => {
265
  try {
266
- const msg = JSON.parse(event.data);
267
- handleWSMessage(msg);
268
  } catch (e) {
269
  console.error('WS parse error', e);
270
  }
@@ -314,31 +303,44 @@
314
  display_name: msg.display_name,
315
  avatar_path: msg.avatar_path
316
  };
 
317
  msg.online_users.forEach(u => State.onlineUsers.set(u.id, u));
318
  updateHeader();
319
  updateOnlineBadge();
320
  break;
321
 
322
- case 'new_message':
323
- if (State.messages.has(msg.message.id)) return;
324
- State.messages.set(msg.message.id, msg.message);
325
- State.messageOrder.push(msg.message.id);
326
- renderMessage(msg.message);
 
 
 
 
 
 
 
 
 
327
  if (State.isAtBottom) scrollToBottom();
328
- if (document.visibilityState === 'visible') sendMarkRead(msg.message.id);
329
  if (document.visibilityState !== 'visible' && Notification.permission === 'granted') {
330
- new Notification(`${msg.message.sender_display_name}`, {
331
- body: msg.message.content || 'Sent a file',
332
  icon: '/static/icon.png'
333
  });
334
  }
335
  break;
 
336
 
337
  case 'messages_loaded':
338
- msg.messages.forEach(m => {
 
 
339
  if (!State.messages.has(m.id)) {
340
  State.messages.set(m.id, m);
341
- State.messageOrder.unshift(m.id);
342
  }
343
  });
344
  State.hasMore = msg.has_more;
@@ -372,25 +374,27 @@
372
 
373
  case 'message_edited':
374
  if (State.messages.has(msg.message_id)) {
375
- const msgObj = State.messages.get(msg.message_id);
376
- msgObj.content = msg.content;
377
- msgObj.is_edited = true;
378
  updateMessageBubble(msg.message_id);
379
  }
380
  break;
381
 
382
  case 'message_deleted':
383
  if (State.messages.has(msg.message_id)) {
384
- const msgObj = State.messages.get(msg.message_id);
385
- msgObj.is_deleted = true;
386
  updateMessageBubble(msg.message_id);
387
  }
388
  break;
389
 
390
  case 'message_read':
391
- if (State.messages.has(msg.message_id) && State.messages.get(msg.message_id).sender_id === State.user.id) {
392
- State.messages.get(msg.message_id).status = 'read';
393
- updateMessageBubble(msg.message_id);
 
 
 
394
  }
395
  break;
396
 
@@ -403,7 +407,20 @@
403
 
404
  case 'error':
405
  showToast(msg.message, 'error');
 
 
 
 
 
 
 
 
 
 
 
 
406
  }
 
407
  }
408
 
409
  // =============================================
@@ -415,6 +432,12 @@
415
  }
416
  }
417
 
 
 
 
 
 
 
418
  function sendMarkRead(messageId) {
419
  if (State.ws?.readyState === WebSocket.OPEN) {
420
  State.ws.send(JSON.stringify({ type: 'mark_read', up_to_message_id: messageId }));
@@ -424,82 +447,204 @@
424
  // =============================================
425
  // Rendering
426
  // =============================================
427
- function renderMessage(msg) {
 
 
 
 
 
 
 
 
 
 
 
428
  if (DOM.messagesList.querySelector(`[data-id="${msg.id}"]`)) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
429
  const wrapper = document.createElement('div');
430
- wrapper.className = `message-wrapper ${msg.sender_id === State.user.id ? 'self' : 'other'}`;
431
  wrapper.dataset.id = msg.id;
432
  wrapper.innerHTML = buildMessageHTML(msg);
433
- DOM.messagesList.appendChild(wrapper);
434
  }
435
 
436
  function buildMessageHTML(msg) {
437
- if (msg.is_deleted) return '<div class="message-bubble deleted">This message was deleted</div>';
 
 
 
438
  const isSelf = msg.sender_id === State.user?.id;
 
439
  let html = '';
440
- // reply indicator
 
 
 
 
 
 
 
 
 
441
  if (msg.reply_to_id && State.messages.has(msg.reply_to_id)) {
442
  const reply = State.messages.get(msg.reply_to_id);
443
- html += `<div class="reply-indicator"><i class="fas fa-reply"></i><span>${reply.sender_username}: ${truncate(reply.content, 50)}</span></div>`;
 
 
 
 
444
  }
 
445
  html += `<div class="message-bubble">`;
446
- html += `<div class="message-text">${escapeHtml(msg.content)}</div>`;
447
- if (msg.file_path) html += buildAttachmentHTML(msg);
 
 
 
 
 
 
 
 
 
 
448
  html += `<div class="message-meta">`;
449
- html += `<span class="message-time">${formatTime(msg.timestamp_ms)}</span>`;
450
- if (msg.is_edited) html += ' <span class="edited-label">edited</span>';
451
  if (isSelf) {
452
- const icon = msg.status === 'read' ? '<i class="fas fa-check-double"></i>' : '<i class="fas fa-check"></i>';
453
- html += `<span class="message-status ${msg.status==='read'?'read':''}">${icon}</span>`;
 
 
454
  }
455
- html += `</div><div class="message-actions">`;
456
- html += `<button onclick="InfinityChat.replyTo(${msg.id})"><i class="fas fa-reply"></i></button>`;
 
 
 
 
 
 
 
 
 
 
 
 
457
  if (isSelf) {
458
- html += `<button onclick="InfinityChat.editMessage(${msg.id})"><i class="fas fa-pen"></i></button>`;
459
- html += `<button onclick="InfinityChat.deleteMessage(${msg.id})"><i class="fas fa-trash"></i></button>`;
 
 
460
  }
461
- html += `</div></div>`;
462
  return html;
463
  }
464
 
465
  function buildAttachmentHTML(msg) {
466
- const url = `${API}/api/download/${encodeURIComponent(msg.file_path)}?token=${encodeURIComponent(State.token)}`;
467
- if (msg.file_type && msg.file_type.startsWith('image/')) {
468
- return `<div class="attachment"><img src="${url}" alt="${msg.file_name}" loading="lazy" onclick="window.open(this.src)"></div>`;
469
- } else if (msg.file_type && msg.file_type.startsWith('video/')) {
470
- return `<div class="attachment"><video controls preload="metadata"><source src="${url}" type="${msg.file_type}"></video></div>`;
471
- } else if (msg.file_type && msg.file_type.startsWith('audio/')) {
472
- return `<div class="attachment"><audio controls src="${url}"></audio></div>`;
473
- } else {
474
- return `<div class="attachment-file" onclick="window.open('${url}')">
475
- <i class="fas fa-file"></i>
476
- <div class="file-info">
477
- <div class="file-name">${msg.file_name || 'File'}</div>
478
- <div class="file-size">${formatFileSize(msg.file_size)}</div>
479
- </div>
480
- </div>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
481
  }
 
 
 
 
 
 
 
 
 
482
  }
483
 
484
  function updateMessageBubble(id) {
485
  const el = DOM.messagesList.querySelector(`[data-id="${id}"]`);
486
  if (!el) return;
487
  const msg = State.messages.get(id);
488
- if (msg) el.innerHTML = buildMessageHTML(msg);
 
 
 
489
  }
490
 
491
  function renderAllMessages() {
 
 
 
 
492
  DOM.messagesList.innerHTML = '';
 
493
  if (State.messageOrder.length === 0) {
494
  DOM.emptyState.style.display = 'flex';
 
 
 
 
 
 
 
 
 
 
 
 
 
495
  } else {
496
- DOM.emptyState.style.display = 'none';
497
- State.messageOrder.forEach(id => {
498
- const msg = State.messages.get(id);
499
- if (msg) renderMessage(msg);
500
- });
501
  }
502
- if (State.isAtBottom) scrollToBottom();
503
  }
504
 
505
  function scrollToBottom() {
@@ -511,9 +656,10 @@
511
  // =============================================
512
  DOM.messagesScroll.addEventListener('scroll', () => {
513
  const { scrollTop, scrollHeight, clientHeight } = DOM.messagesScroll;
514
- State.isAtBottom = (scrollHeight - scrollTop - clientHeight) < 60;
515
  DOM.scrollBottomBtn.classList.toggle('hidden', State.isAtBottom);
516
  });
 
517
  DOM.scrollBottomBtn.addEventListener('click', scrollToBottom);
518
 
519
  // =============================================
@@ -524,6 +670,14 @@
524
  sendMessage();
525
  });
526
 
 
 
 
 
 
 
 
 
527
  DOM.messageInput.addEventListener('input', () => {
528
  const hasContent = DOM.messageInput.value.trim().length > 0 || !!State.pendingFile;
529
  DOM.sendBtn.disabled = !hasContent;
@@ -555,6 +709,7 @@
555
 
556
  State.ws.send(JSON.stringify(payload));
557
  DOM.messageInput.value = '';
 
558
  clearReply();
559
  clearPendingFile();
560
  autoResize();
@@ -564,19 +719,17 @@
564
  function sendTyping(isTyping) {
565
  if (!State.ws || State.ws.readyState !== WebSocket.OPEN) return;
566
  clearTimeout(window.__typingTimer);
 
567
  if (isTyping) {
568
- State.ws.send(JSON.stringify({ type: 'typing', is_typing: true }));
569
  window.__typingTimer = setTimeout(() => sendTyping(false), 2000);
570
- } else {
571
- State.ws.send(JSON.stringify({ type: 'typing', is_typing: false }));
572
  }
573
  }
574
 
575
  function updateTypingIndicator() {
576
- const users = Array.from(State.typingUsers.values()).filter(u => u.user_id !== State.user.id);
577
  DOM.typingIndicator.classList.toggle('hidden', users.length === 0);
578
  if (users.length > 0) {
579
- DOM.typingText.textContent = users.map(u => u.display_name).join(', ') + ' typing...';
580
  }
581
  }
582
 
@@ -587,8 +740,8 @@
587
  const msg = State.messages.get(messageId);
588
  if (!msg) return;
589
  State.replyTo = msg;
590
- DOM.replyUser.textContent = msg.sender_username;
591
- DOM.replyText.textContent = truncate(msg.content, 50);
592
  DOM.replyPreview.classList.remove('hidden');
593
  DOM.messageInput.focus();
594
  }
@@ -638,7 +791,7 @@
638
  });
639
 
640
  async function uploadChunked(file) {
641
- const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
642
  const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
643
  const uploadId = generateId();
644
 
@@ -667,18 +820,19 @@
667
  if (data.status === 'complete') {
668
  State.pendingFile = {
669
  file_path: data.file_path,
670
- file_type: data.file_type,
671
  file_name: data.file_name,
672
  file_size: data.file_size
673
  };
674
  showUploadPreview(data.file_name);
675
  DOM.uploadProgress.classList.add('hidden');
 
676
  return;
677
  }
678
  } catch (err) {
679
- showToast('Upload failed', 'error');
680
  DOM.uploadProgress.classList.add('hidden');
681
- break;
682
  }
683
  }
684
  DOM.uploadProgress.classList.add('hidden');
@@ -693,7 +847,6 @@
693
  function showUploadPreview(filename) {
694
  DOM.uploadPreview.classList.remove('hidden');
695
  DOM.uploadFilename.textContent = filename;
696
- DOM.sendBtn.disabled = false;
697
  }
698
 
699
  function clearPendingFile() {
@@ -701,10 +854,13 @@
701
  DOM.uploadPreview.classList.add('hidden');
702
  }
703
 
704
- DOM.cancelUpload.addEventListener('click', clearPendingFile);
 
 
 
705
 
706
  // =============================================
707
- // Emoji Picker (basic)
708
  // =============================================
709
  DOM.emojiBtn.addEventListener('click', (e) => {
710
  e.stopPropagation();
@@ -721,14 +877,14 @@
721
  });
722
 
723
  const emojiSets = {
724
- smileys: ['😀','😃','😄','😁','😅','😂','🤣','😊','😇','🙂','😉','😌','😍','🥰','😘','😗','😋','😛','😜','🤪','😝','🤑','🤗','🤭','🤫','🤔','🤐','🤨','😐','😑','😶','😏','😒','🙄','😬','😮','🤥','😌','😔','😪','🤤','😴','😷','🤒','🤕','🤢','🤮','🤧','😵','🤯','🥵','🥶','😎','🤓','🧐','😕','😟','🙁','😮','😯','😲','😳','🥺','😦','😧','😨','😰','😥','😢','😭','😱','😖','😣','😞','😓','😩','😫','🥱','😤','😡','😠','🤬','😈','👿','💀','☠️','💩','🤡','👹','👺','👻','👽','👾','🤖'],
725
- people: ['👶','🧒','👦','👧','🧑','👱','👨','🧔','👩','🧓','👴','👵','👲','👳','🧕','👮','👷','💂','🕵️','🧑‍⚕️','👨‍⚕️','👩‍⚕️','🧑‍🎓','👨‍🎓','👩‍🎓','🧑‍🏫','👨‍🏫','👩‍🏫','🧑‍⚖️','👨‍⚖️','👩‍⚖️','🧑‍🌾','👨‍🌾','👩‍🌾','🧑‍🍳','👨‍🍳','👩‍🍳','🧑‍🔧','👨‍🔧','👩‍🔧','🧑‍🏭','👨‍🏭','👩‍🏭','🧑‍💼','👨‍💼','👩‍💼','🧑‍🔬','👨‍🔬','👩‍🔬','🧑‍💻','👨‍💻','👩‍💻','🧑‍🎤','👨‍🎤','👩‍🎤','🧑‍🎨','👨‍🎨','👩‍🎨','🧑‍✈️','👨‍✈️','👩‍✈️','🧑‍🚀','👨‍🚀','👩‍🚀','🧑‍🚒','👨‍🚒','👩‍🚒','👮‍♀️','👮‍♂️','🕵️‍♀️','🕵️‍♂️','💂‍♀️','💂‍♂️','👷‍♀️','👷‍♂️'],
726
- animals: ['🐶','🐱','🐭','🐹','🐰','🦊','🐻','🐼','🐻‍❄️','🐨','🐯','🦁','🐮','🐷','🐸','🐵','🙈','🙉','🙊','🐒','🐔','🐧','🐦','🐤','🐣','🐥','🦆','🦅','🦉','🦇','🐺','🐗','🐴','🦄','🐝','🪱','🐛','🦋','🐌','🐞','🐜','🪰','🪲','🪳','🦟','🦗','🕷️','🦂','🐢','🐍','🦎','🦖','🦕','🐙','🦑','🦐','🦞','🦀','🐡','🐠','🐟','🐬','🐳','🐋','🦈','🐊','🐅','🐆','🦓','🦍','🦧','🐘','🦛','🦏','🐪','🐫','🦒','🦘','🐃','🐂','🐄','🐎','🐖','🐏','🐑','🦙','🐐','🦌','🐕','🐩','🦮','🐕‍🦺','🐈','🐈‍⬛','🐓','🦃','🦤','🦚','🦜','🦢','🦩','🕊️','🐇','🦝','🦨','🦡','🦦','🦥','🐁','🐀','🐿️','🦔','🐾','🐉','🐲'],
727
- food: ['🍏','🍎','🍐','🍊','🍋','🍌','🍉','🍇','🍓','🫐','🍈','🍒','🍑','🥭','🍍','🥥','🥝','🍅','🍆','🥑','🥦','🥬','🥒','🌶️','🌽','🥕','🫒','🧄','🧅','🥔','🍠','🥐','🍞','🥖','🥨','🧀','🥚','🍳','🧈','🥞','🧇','🥓','🥩','🍗','🍖','🦴','🌭','🍔','🍟','🍕','🫓','🥪','🥙','🧆','🌮','🌯','🫔','🥗','🥘','🫕','🥫','🍝','🍜','🍲','🍛','🍣','🍱','🥟','🦪','🍤','🍙','🍚','🍘','🍥','🥠','🥮','🍢','🍡','🍧','🍨','🍦','🥧','🧁','🍰','🎂','🍮','🍭','🍬','🍫','🍿','🍩','🍪','🌰','🥜','🍯','🥛','🍼','🫖','☕','🍵','🧃','🥤','🧋','🍶','🍺','🍻','🥂','🍷','🥃','🍸','🍹','🧉','🍾','🧊','🥄','🍴','🍽️','🥣','🥡','🥢','🧂'],
728
- activities: ['⚽','🏀','🏈','⚾','🥎','🎾','🏐','🏉','🥏','🎱','🪀','🏓','🏸','🏒','🏑','🥍','🏏','🪃','🥅','⛳','🪁','🏹','🎣','🤿','🥊','🥋','🎽','🛹','🛼','🛷','⛸️','🥌','🎿','⛷️','🏂','🪂','🏋️','🤼','🤸','🤺','⛹️','🤾','🏌️','🏇','🧘','🏄','🏊','🤽','🚣','🧗','🚵','🚴','🏆','🥇','🥈','🥉','🏅','🎖️','🏵️','🎗️','🎫','🎟️','🎪','🤹','🎭','🩰','🎨','🎬','🎤','🎧','🎼','🎹','🥁','🪘','🎷','🎺','🪗','🎸','🪕','🎻','🎲','♟️','🎯','🎳','🎮','🎰','🧩'],
729
- objects: ['⌚','📱','💻','⌨️','🖥️','🖨️','🖱️','🖲️','🕹️','🗜️','💽','💾','💿','📀','📼','📷','📸','📹','🎥','📽️','🎞️','📞','☎️','📟','📠','📺','📻','🎙️','🎚️','🎛️','🧭','⏱️','⏲️','⏰','🕰️','⌛','📡','🔋','🔌','💡','🔦','🕯️','🪔','🧯','🛢️','💸','💵','💴','💶','💷','🪙','💰','💳','💎','⚖️','🪜','🧰','🪛','🔧','🔨','⚒️','🛠️','⛏️','🪚','🔩','⚙️','🪤','🧱','⛓️','🧲','🔫','💣','🧨','🪓','🔪','🗡️','⚔️','🛡️','🚬','⚰️','🪦','⚱️','🏺','🔮','📿','🧿','💈','⚗️','🔭','🔬','🕳️','🩻','🩹','🩺','💊','💉','🩸','🧬','🦠','🧫','🧪','🌡️','🧹','🪠','🧺','🧻','🚽','🚰','🚿','🛁','🛀','🧼','🪥','🪒','🧽','🪣','🧴','🪢','🧷','🧹','🧺','🧻','🧼','🪥','🪒','🧽','🪣','🧴','🪢','🧷'],
730
- symbols: ['❤️','🧡','💛','💚','💙','💜','🖤','🤍','🤎','💔','❣️','💕','💞','💓','💗','💖','💘','💝','💟','☮️','✝️','☪️','🕉️','☸️','✡️','🔯','🕎','☯️','☦️','🛐','⛎','♈','♉','♊','♋','♌','♍','♎','♏','♐','♑','♒','♓','🆔','⚛️','🉑','☢️','☣️','📴','📳','🈶','🈚','🈸','🈺','🈷️','✴️','🆚','💮','🉐','㊙️','㊗️','🈴','🈵','🈹','🈲','🅰️','🅱️','🆎','🆑','🅾️','🆘','❌','⭕','🛑','⛔','📛','🚫','💯','💢','♨️','🚷','🚯','🚳','🚱','🔞','📵','🚭','❗','❕','❓','❔','‼️','⁉️','🔅','🔆','〽️','⚠️','🚸','🔱','⚜️','🔰','♻️','✅','🈯','💹','❇️','✳️','❎','🌐','💠','Ⓜ️','🌀','💤','🏧','🚾','♿','🅿️','🛗','🈳','🈂️','🛂','🛃','🛄','🛅','🚰','🚹','🚺','🚼','⚧','🚻','🚮','🎦','📶','🈁','🔣','ℹ️','🔤','🔡','🔠','🆖','🆗','🆙','🆒','🆕','🆓','0️⃣','1️⃣','2️⃣','3️⃣','4️⃣','5️⃣','6️⃣','7️⃣','8️⃣','9️⃣','🔟','🔢','#️⃣','*️⃣','⏏️','▶️','⏸️','⏯️','⏹️','⏺️','⏭️','⏮️','⏩','⏪','⏫','⏬','◀️','🔼','🔽','➡️','⬅️','⬆️','⬇️','↗️','↘️','↙️','↖️','↕️','↔️','↪️','↩️','⤴️','⤵️','🔀','🔁','🔂','🔄','🔃','🎵','🎶','➕','➖','➗','✖️','♾️','💲','💱','™️','©️','®️','〰️','➰','➿','🔚','🔙','🔛','🔝','🔜','✔️','☑️','🔘','🔴','🟠','🟡','🟢','🔵','🟣','⚫','⚪','🟤','🔺','🔻','🔸','🔹','🔶','🔷','🔳','🔲','▪️','▫️','◾','◽','◼️','◻️','🟥','🟧','🟨','🟩','🟦','🟪','⬛','⬜','🟫'],
731
- flags: ['🏁','🚩','🎌','🏴','🏳️','🏳️‍🌈','🏳️‍⚧️','🏴‍☠️','🇿🇦','🇸🇽','🇸🇰','🇸🇮','����🇬','🇸🇪','🇸🇩','🇸🇷','🇸🇸','🇸🇹','🇸🇻','🇸🇾','🇸🇿','🇹🇨','🇹🇩','🇹🇫','🇹🇬','🇹🇭','🇹🇯','🇹🇰','🇹🇱','🇹🇲','🇹🇳','🇹🇴','🇹🇷','🇹🇹','🇹🇻','🇹🇼','🇹🇿','🇺🇦','🇺🇬','🇺🇳','🇺🇸','🇺🇾','🇺🇿','🇻🇦','🇻🇨','🇻🇪','🇻🇬','🇻🇮','🇻🇳','🇻🇺','🇼🇫','🇼🇸','🇽🇰','🇾🇪','🇾🇹','🇿🇦','🇿🇲','🇿🇼']
732
  };
733
 
734
  document.querySelectorAll('.emoji-cat').forEach(btn => {
@@ -742,7 +898,7 @@
742
 
743
  function renderEmojis(category) {
744
  const emojis = emojiSets[category] || [];
745
- DOM.emojiGrid.innerHTML = emojis.map(e => `<button class="emoji-item">${e}</button>`).join('');
746
  DOM.emojiGrid.querySelectorAll('.emoji-item').forEach(btn => {
747
  btn.addEventListener('click', () => {
748
  DOM.messageInput.value += btn.textContent;
@@ -758,6 +914,7 @@
758
  // =============================================
759
  DOM.loadOlderBtn.addEventListener('click', () => {
760
  if (State.ws?.readyState === WebSocket.OPEN && State.hasMore && State.cursor) {
 
761
  State.ws.send(JSON.stringify({ type: 'load_messages', cursor: State.cursor, limit: 50 }));
762
  }
763
  });
@@ -788,14 +945,22 @@
788
  State.onlineUsers.forEach(u => {
789
  const div = document.createElement('div');
790
  div.className = 'user-item';
791
- div.innerHTML = `<div class="user-avatar"><i class="fas fa-user-circle"></i></div>
792
- <div class="user-info">
793
- <div class="user-name">${u.display_name || u.username}</div>
794
- <div class="user-status-text">${u.status || 'online'}</div>
795
- </div>`;
 
 
 
 
 
 
 
 
796
  DOM.onlineUsersList.appendChild(div);
797
  });
798
- DOM.onlineTotal.textContent = `${State.onlineUsers.size} users online`;
799
  }
800
 
801
  // =============================================
@@ -810,8 +975,9 @@
810
  function openSettings() {
811
  DOM.settingsDisplayName.value = State.user?.display_name || '';
812
  DOM.settingsUsername.value = State.user?.username || '';
 
813
  if (State.user?.avatar_path) {
814
- DOM.settingsAvatarImg.src = `${API}/api/download/${encodeURIComponent(State.user.avatar_path)}?token=${encodeURIComponent(State.token)}`;
815
  DOM.settingsAvatarImg.classList.remove('hidden');
816
  DOM.settingsAvatarPreview.classList.add('hidden');
817
  } else {
@@ -819,7 +985,6 @@
819
  DOM.settingsAvatarPreview.classList.remove('hidden');
820
  }
821
 
822
- // Theme selection
823
  DOM.themeOptions.forEach(opt => {
824
  opt.classList.toggle('active', opt.dataset.theme === State.theme);
825
  });
@@ -839,58 +1004,63 @@
839
  async function saveSettings() {
840
  const newName = DOM.settingsDisplayName.value.trim();
841
  if (newName && newName !== State.user?.display_name) {
842
- await fetch(`${API}/api/profile?display_name=${encodeURIComponent(newName)}`, {
843
- method: 'PATCH',
844
- headers: { 'X-Auth-Token': State.token }
845
- });
846
- State.user.display_name = newName;
847
- updateHeader();
848
- showToast('Profile updated', 'success');
 
 
 
 
 
 
849
  }
850
 
851
- // Avatar
852
  const avatarFile = DOM.avatarInput.files[0];
853
  if (avatarFile) {
854
  const formData = new FormData();
855
  formData.append('file', avatarFile);
856
- const res = await fetch(`${API}/api/profile/avatar`, {
857
- method: 'POST',
858
- body: formData,
859
- headers: { 'X-Auth-Token': State.token }
860
- });
861
- if (res.ok) {
862
- const data = await res.json();
863
- State.user.avatar_path = data.avatar_path;
864
- DOM.settingsAvatarImg.src = `${API}/api/download/${encodeURIComponent(data.avatar_path)}?token=${encodeURIComponent(State.token)}`;
865
- DOM.settingsAvatarImg.classList.remove('hidden');
866
- DOM.settingsAvatarPreview.classList.add('hidden');
867
- showToast('Avatar updated', 'success');
 
 
 
 
 
868
  }
869
  }
870
 
871
- // Theme
872
  const selectedTheme = document.querySelector('.theme-option.active')?.dataset.theme || 'light';
873
- if (selectedTheme !== State.theme || selectedTheme === 'custom') {
874
- if (selectedTheme === 'custom') {
875
- State.customColors.self = DOM.customSelfColor.value;
876
- State.customColors.other = DOM.customOtherColor.value;
877
- State.customColors.text = DOM.customTextColor.value;
878
- localStorage.setItem('custom-self', State.customColors.self);
879
- localStorage.setItem('custom-other', State.customColors.other);
880
- localStorage.setItem('custom-text', State.customColors.text);
881
- }
882
- applyTheme(selectedTheme);
883
  }
 
884
 
885
  DOM.settingsModal.classList.add('hidden');
886
  }
887
 
888
- // Custom color picker listeners
889
- DOM.customSelfColor.addEventListener('input', () => DOM.selfColorHex.textContent = DOM.customSelfColor.value);
890
- DOM.customOtherColor.addEventListener('input', () => DOM.otherColorHex.textContent = DOM.customOtherColor.value);
891
- DOM.customTextColor.addEventListener('input', () => DOM.textColorHex.textContent = DOM.customTextColor.value);
892
 
893
- // Theme selection clicks
894
  DOM.themeOptions.forEach(opt => {
895
  opt.addEventListener('click', () => {
896
  DOM.themeOptions.forEach(o => o.classList.remove('active'));
@@ -907,7 +1077,6 @@
907
  });
908
  });
909
 
910
- // Avatar upload button
911
  DOM.avatarUploadBtn.addEventListener('click', () => DOM.avatarInput.click());
912
  DOM.avatarInput.addEventListener('change', () => {
913
  const file = DOM.avatarInput.files[0];
@@ -935,19 +1104,32 @@
935
  }
936
 
937
  function escapeHtml(text) {
 
938
  const div = document.createElement('div');
939
  div.textContent = text;
940
  return div.innerHTML;
941
  }
942
 
943
  function formatTime(ms) {
 
944
  const date = new Date(ms);
945
  const now = new Date();
946
  const diff = now - date;
947
- if (diff < 60000) return 'now';
 
 
 
 
 
948
  if (date.toDateString() === now.toDateString()) {
949
  return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
950
  }
 
 
 
 
 
 
951
  return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' +
952
  date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
953
  }
@@ -979,9 +1161,13 @@
979
  State.user = data.user;
980
  showChat();
981
  connectWebSocket();
 
982
  return;
983
  }
984
  } catch (e) {}
 
 
 
985
  }
986
  showAuth();
987
  }
@@ -995,11 +1181,22 @@
995
  DOM.authScreen.classList.remove('active');
996
  DOM.chatScreen.classList.add('active');
997
  updateHeader();
 
998
  }
999
 
1000
  function updateHeader() {
1001
- if (State.user) {
1002
- DOM.headerDisplayName.textContent = State.user.display_name;
 
 
 
 
 
 
 
 
 
 
1003
  }
1004
  }
1005
 
@@ -1016,39 +1213,39 @@
1016
  });
1017
  });
1018
 
1019
- // Logout button
1020
  document.getElementById('logout-btn').addEventListener('click', async () => {
1021
  if (State.token) {
1022
- await fetch(`${API}/api/auth/logout`, {
1023
- method: 'POST',
1024
- headers: { 'X-Auth-Token': State.token }
1025
- });
 
 
1026
  }
1027
- if (State.ws) State.ws.close();
1028
- State.ws = null;
1029
  localStorage.removeItem('token');
1030
  State.token = null;
1031
  State.user = null;
1032
  State.messages.clear();
1033
  State.messageOrder = [];
1034
  State.onlineUsers.clear();
 
 
 
1035
  DOM.messagesList.innerHTML = '';
1036
  DOM.emptyState.style.display = 'flex';
1037
  showAuth();
1038
  updateOnlineBadge();
1039
  });
1040
 
1041
- // Request notification permission
1042
  if (window.Notification && Notification.permission === 'default') {
1043
  Notification.requestPermission();
1044
  }
1045
 
1046
- // Expose public methods
1047
- window.InfinityChat = {
1048
- replyTo,
1049
- editMessage,
1050
- deleteMessage
1051
- };
1052
 
1053
  init();
1054
  })();
 
31
  // DOM Cache
32
  // =============================================
33
  const DOM = {
 
34
  authScreen: document.getElementById('auth-screen'),
35
  chatScreen: document.getElementById('chat-screen'),
 
 
36
  loginForm: document.getElementById('login-form'),
37
  signupForm: document.getElementById('signup-form'),
38
  authTabs: document.querySelectorAll('.auth-tab'),
39
  authError: document.getElementById('auth-error'),
40
  authErrorText: document.getElementById('auth-error-text'),
41
  authLoading: document.getElementById('auth-loading'),
 
 
42
  headerDisplayName: document.getElementById('header-display-name'),
43
  headerAvatar: document.getElementById('header-avatar'),
44
  headerAvatarPlaceholder: document.getElementById('header-avatar-placeholder'),
45
  headerStatus: document.getElementById('header-status'),
46
  onlineBadge: document.getElementById('online-badge'),
 
 
47
  connectionStatus: document.getElementById('connection-status'),
48
  connectionText: document.getElementById('connection-text'),
 
 
49
  messagesList: document.getElementById('messages-list'),
50
  messagesScroll: document.getElementById('messages-scroll'),
51
  emptyState: document.getElementById('empty-state'),
52
  loadOlderBtn: document.getElementById('load-older-btn'),
53
  scrollBottomBtn: document.getElementById('scroll-bottom-btn'),
 
 
54
  typingIndicator: document.getElementById('typing-indicator'),
55
  typingText: document.getElementById('typing-text'),
 
 
56
  replyPreview: document.getElementById('reply-preview'),
57
  replyUser: document.getElementById('reply-user'),
58
  replyText: document.getElementById('reply-text'),
 
 
59
  composeForm: document.getElementById('compose-form'),
60
  messageInput: document.getElementById('message-input'),
61
  sendBtn: document.getElementById('send-btn'),
62
  attachBtn: document.getElementById('attach-btn'),
63
  emojiBtn: document.getElementById('emoji-btn'),
64
  fileInput: document.getElementById('file-input'),
 
 
65
  uploadPreview: document.getElementById('upload-preview'),
66
  uploadFilename: document.getElementById('upload-filename'),
67
  cancelUpload: document.getElementById('cancel-upload'),
68
  uploadProgress: document.getElementById('upload-progress'),
69
  progressFill: document.getElementById('progress-fill'),
70
  progressText: document.getElementById('progress-text'),
 
 
71
  settingsModal: document.getElementById('settings-modal'),
72
  onlineModal: document.getElementById('online-modal'),
73
  emojiPicker: document.getElementById('emoji-picker'),
74
  emojiGrid: document.getElementById('emoji-grid'),
 
 
75
  settingsDisplayName: document.getElementById('settings-display-name'),
76
  settingsUsername: document.getElementById('settings-username'),
77
  settingsAvatarPreview: document.getElementById('settings-avatar-preview'),
 
86
  selfColorHex: document.getElementById('self-color-hex'),
87
  otherColorHex: document.getElementById('other-color-hex'),
88
  textColorHex: document.getElementById('text-color-hex'),
 
 
89
  onlineBtn: document.getElementById('online-btn'),
90
  onlineUsersList: document.getElementById('online-users-list'),
91
  onlineTotal: document.getElementById('online-total'),
 
 
92
  toastContainer: document.getElementById('toast-container')
93
  };
94
 
95
  const API = window.location.origin;
96
 
97
+ // =============================================
98
+ // File URL helper - uses query param for token
99
+ // so img src, video src, window.open all work
100
+ // =============================================
101
+ function fileUrl(filePath) {
102
+ return `${API}/api/download/${encodeURIComponent(filePath)}?t=${encodeURIComponent(State.token)}`;
103
+ }
104
+
105
  // =============================================
106
  // Theme
107
  // =============================================
 
118
  document.documentElement.style.setProperty('--bubble-other-text', textColor);
119
  const isLight = (c) => {
120
  const r = parseInt(c.slice(1,3),16), g = parseInt(c.slice(3,5),16), b = parseInt(c.slice(5,7),16);
121
+ return (0.299*r + 0.587*g + 0.114*b) > 150;
122
  };
123
  document.documentElement.style.setProperty('--bubble-self-text', isLight(selfColor) ? '#111827' : '#FFFFFF');
124
  } else {
 
125
  document.documentElement.style.removeProperty('--bubble-self');
126
  document.documentElement.style.removeProperty('--bubble-other');
127
  document.documentElement.style.removeProperty('--bubble-other-text');
 
131
 
132
  applyTheme(State.theme);
133
 
134
+ // =============================================
135
+ // Timestamp updater - refreshes every 30s
136
+ // =============================================
137
+ function startTimestampUpdater() {
138
+ setInterval(() => {
139
+ document.querySelectorAll('.message-time[data-ts]').forEach(el => {
140
+ el.textContent = formatTime(parseInt(el.dataset.ts));
141
+ });
142
+ }, 30000);
143
+ }
144
+
145
  // =============================================
146
  // Toast Notifications
147
  // =============================================
148
  function showToast(message, type = 'info') {
149
  const toast = document.createElement('div');
150
  toast.className = `toast toast-${type}`;
151
+ const icons = { success: 'check-circle', error: 'exclamation-circle', warning: 'exclamation-triangle', info: 'info-circle' };
152
+ toast.innerHTML = `<i class="fas fa-${icons[type] || 'info-circle'}"></i><span>${message}</span>`;
 
 
 
153
  DOM.toastContainer.appendChild(toast);
154
  setTimeout(() => toast.remove(), 4000);
155
  }
 
253
 
254
  State.ws.onmessage = (event) => {
255
  try {
256
+ handleWSMessage(JSON.parse(event.data));
 
257
  } catch (e) {
258
  console.error('WS parse error', e);
259
  }
 
303
  display_name: msg.display_name,
304
  avatar_path: msg.avatar_path
305
  };
306
+ State.onlineUsers.clear();
307
  msg.online_users.forEach(u => State.onlineUsers.set(u.id, u));
308
  updateHeader();
309
  updateOnlineBadge();
310
  break;
311
 
312
+ case 'new_message': {
313
+ const m = msg.message;
314
+ // Deduplicate
315
+ if (State.messages.has(m.id)) {
316
+ // Update status if we already have it (e.g. optimistic)
317
+ const existing = State.messages.get(m.id);
318
+ existing.status = m.status;
319
+ updateMessageBubble(m.id);
320
+ return;
321
+ }
322
+ State.messages.set(m.id, m);
323
+ // Insert in correct sorted position
324
+ insertMessageIdSorted(m.id, m.timestamp_ms);
325
+ renderMessage(m, true);
326
  if (State.isAtBottom) scrollToBottom();
327
+ if (document.visibilityState === 'visible') sendMarkRead(m.id);
328
  if (document.visibilityState !== 'visible' && Notification.permission === 'granted') {
329
+ new Notification(m.sender_display_name || m.sender_username, {
330
+ body: m.content || 'Sent a file',
331
  icon: '/static/icon.png'
332
  });
333
  }
334
  break;
335
+ }
336
 
337
  case 'messages_loaded':
338
+ // Sort loaded messages by id ascending before inserting
339
+ const sorted = [...msg.messages].sort((a, b) => a.id - b.id);
340
+ sorted.forEach(m => {
341
  if (!State.messages.has(m.id)) {
342
  State.messages.set(m.id, m);
343
+ insertMessageIdSorted(m.id, m.timestamp_ms);
344
  }
345
  });
346
  State.hasMore = msg.has_more;
 
374
 
375
  case 'message_edited':
376
  if (State.messages.has(msg.message_id)) {
377
+ const m = State.messages.get(msg.message_id);
378
+ m.content = msg.content;
379
+ m.is_edited = true;
380
  updateMessageBubble(msg.message_id);
381
  }
382
  break;
383
 
384
  case 'message_deleted':
385
  if (State.messages.has(msg.message_id)) {
386
+ State.messages.get(msg.message_id).is_deleted = true;
 
387
  updateMessageBubble(msg.message_id);
388
  }
389
  break;
390
 
391
  case 'message_read':
392
+ if (State.messages.has(msg.message_id)) {
393
+ const m = State.messages.get(msg.message_id);
394
+ if (m.sender_id === State.user.id) {
395
+ m.status = 'read';
396
+ updateMessageBubble(msg.message_id);
397
+ }
398
  }
399
  break;
400
 
 
407
 
408
  case 'error':
409
  showToast(msg.message, 'error');
410
+ break;
411
+ }
412
+ }
413
+
414
+ // Keep messageOrder sorted by id ascending at all times
415
+ function insertMessageIdSorted(id, ts) {
416
+ // Binary insert by id
417
+ let lo = 0, hi = State.messageOrder.length;
418
+ while (lo < hi) {
419
+ const mid = (lo + hi) >> 1;
420
+ if (State.messageOrder[mid] < id) lo = mid + 1;
421
+ else hi = mid;
422
  }
423
+ State.messageOrder.splice(lo, 0, id);
424
  }
425
 
426
  // =============================================
 
432
  }
433
  }
434
 
435
+ function requestOnlineUsers() {
436
+ if (State.ws?.readyState === WebSocket.OPEN) {
437
+ State.ws.send(JSON.stringify({ type: 'get_online_users' }));
438
+ }
439
+ }
440
+
441
  function sendMarkRead(messageId) {
442
  if (State.ws?.readyState === WebSocket.OPEN) {
443
  State.ws.send(JSON.stringify({ type: 'mark_read', up_to_message_id: messageId }));
 
447
  // =============================================
448
  // Rendering
449
  // =============================================
450
+
451
+ // Build avatar HTML for a message sender
452
+ function buildSenderAvatarHTML(msg) {
453
+ if (msg.sender_avatar_path) {
454
+ const url = fileUrl(msg.sender_avatar_path);
455
+ return `<img class="msg-avatar" src="${url}" alt="${escapeHtml(msg.sender_display_name || msg.sender_username)}" title="${escapeHtml(msg.sender_display_name || msg.sender_username)}">`;
456
+ }
457
+ const initials = (msg.sender_display_name || msg.sender_username || '?').charAt(0).toUpperCase();
458
+ return `<div class="msg-avatar msg-avatar-placeholder" title="${escapeHtml(msg.sender_display_name || msg.sender_username)}">${initials}</div>`;
459
+ }
460
+
461
+ function renderMessage(msg, append = false) {
462
  if (DOM.messagesList.querySelector(`[data-id="${msg.id}"]`)) return;
463
+ const wrapper = createMessageElement(msg);
464
+ if (append) {
465
+ DOM.messagesList.appendChild(wrapper);
466
+ } else {
467
+ // Find correct position in DOM by id order
468
+ const ids = State.messageOrder;
469
+ const idx = ids.indexOf(msg.id);
470
+ const nextId = ids[idx + 1];
471
+ if (nextId) {
472
+ const nextEl = DOM.messagesList.querySelector(`[data-id="${nextId}"]`);
473
+ if (nextEl) {
474
+ DOM.messagesList.insertBefore(wrapper, nextEl);
475
+ return;
476
+ }
477
+ }
478
+ DOM.messagesList.appendChild(wrapper);
479
+ }
480
+ }
481
+
482
+ function createMessageElement(msg) {
483
+ const isSelf = msg.sender_id === State.user?.id;
484
  const wrapper = document.createElement('div');
485
+ wrapper.className = `message-wrapper ${isSelf ? 'self' : 'other'}`;
486
  wrapper.dataset.id = msg.id;
487
  wrapper.innerHTML = buildMessageHTML(msg);
488
+ return wrapper;
489
  }
490
 
491
  function buildMessageHTML(msg) {
492
+ if (msg.is_deleted) {
493
+ return '<div class="message-bubble deleted"><i class="fas fa-ban"></i> This message was deleted</div>';
494
+ }
495
+
496
  const isSelf = msg.sender_id === State.user?.id;
497
+ const displayName = escapeHtml(msg.sender_display_name || msg.sender_username || 'Unknown');
498
  let html = '';
499
+
500
+ // Sender info row (show for others, and also for self on first load so you know it's you)
501
+ if (!isSelf) {
502
+ html += `<div class="msg-sender-row">`;
503
+ html += buildSenderAvatarHTML(msg);
504
+ html += `<span class="msg-sender-name">${displayName}</span>`;
505
+ html += `</div>`;
506
+ }
507
+
508
+ // Reply indicator
509
  if (msg.reply_to_id && State.messages.has(msg.reply_to_id)) {
510
  const reply = State.messages.get(msg.reply_to_id);
511
+ const replyName = escapeHtml(reply.sender_display_name || reply.sender_username || 'Unknown');
512
+ html += `<div class="reply-indicator">
513
+ <i class="fas fa-reply"></i>
514
+ <span><strong>${replyName}:</strong> ${escapeHtml(truncate(reply.content || (reply.file_name ? '📎 ' + reply.file_name : ''), 60))}</span>
515
+ </div>`;
516
  }
517
+
518
  html += `<div class="message-bubble">`;
519
+
520
+ // Message text (only if non-empty)
521
+ if (msg.content) {
522
+ html += `<div class="message-text">${escapeHtml(msg.content)}</div>`;
523
+ }
524
+
525
+ // Attachment
526
+ if (msg.file_path) {
527
+ html += buildAttachmentHTML(msg);
528
+ }
529
+
530
+ // Meta row: time + edited + status
531
  html += `<div class="message-meta">`;
532
+ html += `<span class="message-time" data-ts="${msg.timestamp_ms}">${formatTime(msg.timestamp_ms)}</span>`;
533
+ if (msg.is_edited) html += ` <span class="edited-label">edited</span>`;
534
  if (isSelf) {
535
+ const isRead = msg.status === 'read';
536
+ html += `<span class="message-status${isRead ? ' read' : ''}">`;
537
+ html += isRead ? '<i class="fas fa-check-double"></i>' : '<i class="fas fa-check"></i>';
538
+ html += `</span>`;
539
  }
540
+ html += `</div>`;
541
+
542
+ // Action buttons
543
+ html += `<div class="message-actions">`;
544
+ html += `<button onclick="InfinityChat.replyTo(${msg.id})" title="Reply"><i class="fas fa-reply"></i></button>`;
545
+ if (isSelf) {
546
+ html += `<button onclick="InfinityChat.editMessage(${msg.id})" title="Edit"><i class="fas fa-pen"></i></button>`;
547
+ html += `<button onclick="InfinityChat.deleteMessage(${msg.id})" title="Delete"><i class="fas fa-trash"></i></button>`;
548
+ }
549
+ html += `</div>`;
550
+
551
+ html += `</div>`; // close message-bubble
552
+
553
+ // Self sender info on right side
554
  if (isSelf) {
555
+ html += `<div class="msg-sender-row msg-sender-row-self">`;
556
+ html += `<span class="msg-sender-name">${displayName}</span>`;
557
+ html += buildSenderAvatarHTML(msg);
558
+ html += `</div>`;
559
  }
560
+
561
  return html;
562
  }
563
 
564
  function buildAttachmentHTML(msg) {
565
+ const url = fileUrl(msg.file_path);
566
+ const ft = (msg.file_type || '').toLowerCase();
567
+ const fname = escapeHtml(msg.file_name || 'File');
568
+
569
+ if (ft.startsWith('image/')) {
570
+ return `<div class="attachment">
571
+ <img src="${url}"
572
+ alt="${fname}"
573
+ loading="lazy"
574
+ onclick="window.open('${url}', '_blank')"
575
+ onerror="this.style.display='none';this.nextSibling.style.display='flex'">
576
+ <div class="attachment-file" style="display:none" onclick="window.open('${url}', '_blank')">
577
+ <i class="fas fa-image"></i>
578
+ <div class="file-info">
579
+ <div class="file-name">${fname}</div>
580
+ <div class="file-size">${formatFileSize(msg.file_size)}</div>
581
+ </div>
582
+ </div>
583
+ </div>`;
584
+ }
585
+
586
+ if (ft.startsWith('video/')) {
587
+ return `<div class="attachment">
588
+ <video controls preload="metadata" onclick="event.stopPropagation()">
589
+ <source src="${url}" type="${escapeHtml(msg.file_type)}">
590
+ Your browser does not support video.
591
+ </video>
592
+ </div>`;
593
+ }
594
+
595
+ if (ft.startsWith('audio/')) {
596
+ return `<div class="attachment">
597
+ <audio controls src="${url}"></audio>
598
+ </div>`;
599
  }
600
+
601
+ // Generic file
602
+ return `<div class="attachment-file" onclick="window.open('${url}', '_blank')">
603
+ <i class="fas fa-file-alt"></i>
604
+ <div class="file-info">
605
+ <div class="file-name">${fname}</div>
606
+ <div class="file-size">${formatFileSize(msg.file_size)}</div>
607
+ </div>
608
+ </div>`;
609
  }
610
 
611
  function updateMessageBubble(id) {
612
  const el = DOM.messagesList.querySelector(`[data-id="${id}"]`);
613
  if (!el) return;
614
  const msg = State.messages.get(id);
615
+ if (!msg) return;
616
+ const isSelf = msg.sender_id === State.user?.id;
617
+ el.className = `message-wrapper ${isSelf ? 'self' : 'other'}`;
618
+ el.innerHTML = buildMessageHTML(msg);
619
  }
620
 
621
  function renderAllMessages() {
622
+ // Save scroll position if loading older messages
623
+ const wasAtBottom = State.isAtBottom;
624
+ const prevScrollHeight = DOM.messagesScroll.scrollHeight;
625
+
626
  DOM.messagesList.innerHTML = '';
627
+
628
  if (State.messageOrder.length === 0) {
629
  DOM.emptyState.style.display = 'flex';
630
+ return;
631
+ }
632
+
633
+ DOM.emptyState.style.display = 'none';
634
+
635
+ // messageOrder is always sorted ascending by id
636
+ State.messageOrder.forEach(id => {
637
+ const msg = State.messages.get(id);
638
+ if (msg) renderMessage(msg, true);
639
+ });
640
+
641
+ if (wasAtBottom) {
642
+ scrollToBottom();
643
  } else {
644
+ // Maintain scroll position when prepending old messages
645
+ const newScrollHeight = DOM.messagesScroll.scrollHeight;
646
+ DOM.messagesScroll.scrollTop += (newScrollHeight - prevScrollHeight);
 
 
647
  }
 
648
  }
649
 
650
  function scrollToBottom() {
 
656
  // =============================================
657
  DOM.messagesScroll.addEventListener('scroll', () => {
658
  const { scrollTop, scrollHeight, clientHeight } = DOM.messagesScroll;
659
+ State.isAtBottom = (scrollHeight - scrollTop - clientHeight) < 80;
660
  DOM.scrollBottomBtn.classList.toggle('hidden', State.isAtBottom);
661
  });
662
+
663
  DOM.scrollBottomBtn.addEventListener('click', scrollToBottom);
664
 
665
  // =============================================
 
670
  sendMessage();
671
  });
672
 
673
+ // Send on Enter (Shift+Enter for newline)
674
+ DOM.messageInput.addEventListener('keydown', (e) => {
675
+ if (e.key === 'Enter' && !e.shiftKey) {
676
+ e.preventDefault();
677
+ sendMessage();
678
+ }
679
+ });
680
+
681
  DOM.messageInput.addEventListener('input', () => {
682
  const hasContent = DOM.messageInput.value.trim().length > 0 || !!State.pendingFile;
683
  DOM.sendBtn.disabled = !hasContent;
 
709
 
710
  State.ws.send(JSON.stringify(payload));
711
  DOM.messageInput.value = '';
712
+ DOM.sendBtn.disabled = true;
713
  clearReply();
714
  clearPendingFile();
715
  autoResize();
 
719
  function sendTyping(isTyping) {
720
  if (!State.ws || State.ws.readyState !== WebSocket.OPEN) return;
721
  clearTimeout(window.__typingTimer);
722
+ State.ws.send(JSON.stringify({ type: 'typing', is_typing: isTyping }));
723
  if (isTyping) {
 
724
  window.__typingTimer = setTimeout(() => sendTyping(false), 2000);
 
 
725
  }
726
  }
727
 
728
  function updateTypingIndicator() {
729
+ const users = Array.from(State.typingUsers.values()).filter(u => u.user_id !== State.user?.id);
730
  DOM.typingIndicator.classList.toggle('hidden', users.length === 0);
731
  if (users.length > 0) {
732
+ DOM.typingText.textContent = users.map(u => u.display_name || u.username).join(', ') + (users.length === 1 ? ' is typing...' : ' are typing...');
733
  }
734
  }
735
 
 
740
  const msg = State.messages.get(messageId);
741
  if (!msg) return;
742
  State.replyTo = msg;
743
+ DOM.replyUser.textContent = msg.sender_display_name || msg.sender_username;
744
+ DOM.replyText.textContent = truncate(msg.content || (msg.file_name ? '📎 ' + msg.file_name : ''), 60);
745
  DOM.replyPreview.classList.remove('hidden');
746
  DOM.messageInput.focus();
747
  }
 
791
  });
792
 
793
  async function uploadChunked(file) {
794
+ const CHUNK_SIZE = 5 * 1024 * 1024;
795
  const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
796
  const uploadId = generateId();
797
 
 
820
  if (data.status === 'complete') {
821
  State.pendingFile = {
822
  file_path: data.file_path,
823
+ file_type: data.file_type || file.type,
824
  file_name: data.file_name,
825
  file_size: data.file_size
826
  };
827
  showUploadPreview(data.file_name);
828
  DOM.uploadProgress.classList.add('hidden');
829
+ DOM.sendBtn.disabled = false;
830
  return;
831
  }
832
  } catch (err) {
833
+ showToast('Upload failed: ' + err.message, 'error');
834
  DOM.uploadProgress.classList.add('hidden');
835
+ return;
836
  }
837
  }
838
  DOM.uploadProgress.classList.add('hidden');
 
847
  function showUploadPreview(filename) {
848
  DOM.uploadPreview.classList.remove('hidden');
849
  DOM.uploadFilename.textContent = filename;
 
850
  }
851
 
852
  function clearPendingFile() {
 
854
  DOM.uploadPreview.classList.add('hidden');
855
  }
856
 
857
+ DOM.cancelUpload.addEventListener('click', () => {
858
+ clearPendingFile();
859
+ DOM.sendBtn.disabled = DOM.messageInput.value.trim().length === 0;
860
+ });
861
 
862
  // =============================================
863
+ // Emoji Picker
864
  // =============================================
865
  DOM.emojiBtn.addEventListener('click', (e) => {
866
  e.stopPropagation();
 
877
  });
878
 
879
  const emojiSets = {
880
+ smileys: ['😀','😃','😄','😁','😅','😂','🤣','😊','😇','🙂','😉','😌','😍','🥰','😘','😗','😋','😛','😜','🤪','😝','🤑','🤗','🤭','🤫','🤔','🤐','🤨','😐','😑','😶','😏','😒','🙄','😬','😮','🤥','😔','😪','🤤','😴','😷','🤒','🤕','🤢','🤮','🤧','😵','🤯','🥵','🥶','😎','🤓','🧐','😕','😟','🙁','😮','😯','😲','😳','🥺','😦','😧','😨','😰','😥','😢','😭','😱','😖','😣','😞','😓','😩','😫','🥱','😤','😡','😠','🤬','😈','👿','💀','☠️','💩','🤡','👹','👺','👻','👽','👾','🤖'],
881
+ people: ['👶','🧒','👦','👧','🧑','👱','👨','🧔','👩','🧓','👴','👵','👲','👳','🧕','👮','👷','💂','🕵️','👨‍⚕️','👩‍⚕️','👨‍🎓','👩‍🎓','👨‍🏫','👩‍🏫','👨‍⚖️','👩‍⚖️','👨‍🌾','👩‍🌾','👨‍🍳','👩‍🍳','👨‍🔧','👩‍🔧','👨‍🏭','👩‍🏭','👨‍💼','👩‍💼','👨‍🔬','👩‍🔬','👨‍💻','👩‍💻','👨‍🎤','👩‍🎤','👨‍🎨','👩‍🎨','👨‍✈️','👩‍✈️','👨‍🚀','👩‍🚀','👨‍🚒','👩‍🚒'],
882
+ animals: ['🐶','🐱','🐭','🐹','🐰','🦊','🐻','🐼','🐨','🐯','🦁','🐮','🐷','🐸','🐵','🙈','🙉','🙊','🐒','🐔','🐧','🐦','🐤','🦆','🦅','🦉','🦇','🐺','🐗','🐴','🦄','🐝','🐛','🦋','🐌','🐞','🐜','🦟','🦗','🕷️','🦂','🐢','🐍','🦎','🐙','🦑','🦐','🦞','🦀','🐡','🐠','🐟','🐬','🐳','🐋','🦈','🐊','🐅','🐆','🦓','🦍','🐘','🦛','🦏','🐪','🐫','🦒','🦘','🐃','🐂','🐄','🐎','🐖','🐏','🐑','🦙','🐐','🦌','🐕','🐩','🐈','🐓','🦃','🦚','🦜','🦢','🦩','🕊️','🐇','🦝','🦨','🦡','🦦','🦥','🐁','🐀','🐿️','🦔'],
883
+ food: ['🍏','🍎','🍐','🍊','🍋','🍌','🍉','🍇','🍓','🫐','🍈','🍒','🍑','🥭','🍍','🥥','🥝','🍅','🍆','🥑','🥦','🥬','🥒','🌶️','🌽','🥕','🧄','🧅','🥔','🍠','🥐','🍞','🥖','🥨','🧀','🥚','🍳','🧈','🥞','🧇','🥓','🥩','🍗','🍖','🌭','🍔','🍟','🍕','🥪','🥙','🧆','🌮','🌯','🥗','🥘','🥫','🍝','🍜','🍲','🍛','🍣','🍱','🥟','🦪','🍤','🍙','🍚','🍘','🍥','🥠','🥮','🍢','🍡','🍧','🍨','🍦','🥧','🧁','🍰','🎂','🍮','🍭','🍬','🍫','🍿','🍩','🍪','🌰','🥜','🍯','🥛','🍼','☕','🍵','🧃','🥤','🧋','🍺','🍻','🥂','🍷','🥃','🍸','🍹','🍾'],
884
+ activities: ['⚽','🏀','🏈','⚾','🥎','🎾','🏐','🏉','🥏','🎱','🏓','🏸','🏒','🏑','🥍','🏏','🥅','⛳','🏹','🎣','🤿','🥊','🥋','🎽','🛹','🛼','🛷','⛸️','🥌','🎿','⛷️','🏂','🪂','🏋️','🤼','🤸','⛹️','🤾','🏌️','🏇','🧘','🏄','🏊','🤽','🚣','🧗','🚵','🚴','🏆','🥇','🥈','🥉','🏅','🎖️','🏵️','🎗️','🎫','🎟️','🎪','🤹','🎭','🩰','🎨','🎬','🎤','🎧','🎼','🎹','🥁','🎷','🎺','🎸','🎻','🎲','♟️','🎯','🎳','🎮','🎰','🧩'],
885
+ objects: ['⌚','📱','💻','⌨️','🖥️','🖨️','🖱️','💽','💾','💿','📀','📼','📷','📸','📹','🎥','📞','☎️','📺','📻','🧭','⏱️','⏲️','⏰','🕰️','⌛','📡','🔋','🔌','💡','🔦','🕯️','🧯','💸','💵','💴','💶','💷','🪙','💰','💳','💎','⚖️','🧰','🔧','🔨','⚒️','🛠️','⛏️','🪚','🔩','⚙️','🧲','🔫','💣','🧨','🔪','🗡️','⚔️','🛡️','🚬','⚰️','🪦','⚱️','🏺','🔮','📿','💈','⚗️','🔭','🔬','💊','💉','🩸','🧬','🦠','🧫','🧪','🌡️','🧹','🧺','🧻','🚽','🚰','🚿','🛁','🧼','🪥','🪒','🧽','🪣','🧴','🪢','🧷'],
886
+ symbols: ['❤️','🧡','💛','💚','💙','💜','🖤','🤍','🤎','💔','❣️','💕','💞','💓','💗','💖','💘','💝','💟','☮️','✝️','☪️','🕉️','☸️','✡️','🔯','🕎','☯️','☦️','🛐','⛎','♈','♉','♊','♋','♌','♍','♎','♏','♐','♑','♒','♓','🆔','⚛️','☢️','☣️','📴','📳','🈶','🈚','🈸','🈺','🈷️','✴️','🆚','💮','🉐','㊙️','㊗️','🈴','🈵','🈹','🈲','🅰️','🅱️','🆎','���','🅾️','🆘','❌','⭕','🛑','⛔','📛','🚫','💯','💢','♨️','❗','❕','❓','❔','‼️','⁉️','⚠️','🚸','🔱','⚜️','🔰','♻️','✅','❇️','✳️','❎','🌐','💠','Ⓜ️','🌀','💤','🏧','♿','🅿️','🚹','🚺','🚻','🚮','🎦','📶','ℹ️','🔤','🔡','🔠','🆖','🆗','🆙','🆒','🆕','🆓','0️⃣','1️⃣','2️⃣','3️⃣','4️⃣','5️⃣','6️⃣','7️⃣','8️⃣','9️⃣','🔟','#️⃣','*️⃣','▶️','⏸️','⏹️','⏺️','⏭️','⏮️','⏩','⏪','⏫','⏬','◀️','🔼','🔽','➡️','⬅️','⬆️','⬇️','↗️','↘️','↙️','↖️','↕️','↔️','↪️','↩️','⤴️','⤵️','🔀','🔁','🔂','🔄','🔃','🎵','🎶','➕','➖','➗','✖️','♾️','💲','💱','™️','©️','®️','〰️','➰','➿','✔️','☑️'],
887
+ flags: ['🏁','🚩','🎌','🏴','🏳️','🏳️‍🌈','🏳️‍⚧️','🏴‍☠️','🇺🇸','🇬🇧','🇨🇦','🇦🇺','🇩🇪','🇫🇷','🇯🇵','🇨🇳','🇷🇺','🇧🇷','🇮🇳','🇰🇷','🇪🇸','🇮🇹','🇲🇽','🇿🇦','🇸🇪','🇳🇴','🇩🇰','🇫🇮','🇳🇱','🇧🇪','🇦🇹','🇨🇭','🇵🇱','🇵🇹','🇬🇷','🇹🇷','🇸🇦','🇦🇪','🇮🇱','🇮🇷','🇵🇰','🇧🇩','🇻🇳','🇹🇭','🇮🇩','🇲🇾','🇵🇭','🇸🇬','🇳🇿','🇦🇷','🇨🇱','🇨🇴','🇵🇪','🇻🇪','🇺🇾','🇪🇨','🇧🇴','🇵🇾','🇬🇹','🇨🇺','🇩🇴']
888
  };
889
 
890
  document.querySelectorAll('.emoji-cat').forEach(btn => {
 
898
 
899
  function renderEmojis(category) {
900
  const emojis = emojiSets[category] || [];
901
+ DOM.emojiGrid.innerHTML = emojis.map(e => `<button class="emoji-item" type="button">${e}</button>`).join('');
902
  DOM.emojiGrid.querySelectorAll('.emoji-item').forEach(btn => {
903
  btn.addEventListener('click', () => {
904
  DOM.messageInput.value += btn.textContent;
 
914
  // =============================================
915
  DOM.loadOlderBtn.addEventListener('click', () => {
916
  if (State.ws?.readyState === WebSocket.OPEN && State.hasMore && State.cursor) {
917
+ State.isAtBottom = false; // Don't auto-scroll when loading older
918
  State.ws.send(JSON.stringify({ type: 'load_messages', cursor: State.cursor, limit: 50 }));
919
  }
920
  });
 
945
  State.onlineUsers.forEach(u => {
946
  const div = document.createElement('div');
947
  div.className = 'user-item';
948
+ let avatarHtml;
949
+ if (u.avatar_path) {
950
+ avatarHtml = `<img src="${fileUrl(u.avatar_path)}" alt="${escapeHtml(u.display_name || u.username)}" style="width:100%;height:100%;object-fit:cover;border-radius:50%">`;
951
+ } else {
952
+ const initials = (u.display_name || u.username || '?').charAt(0).toUpperCase();
953
+ avatarHtml = initials;
954
+ }
955
+ div.innerHTML = `
956
+ <div class="user-avatar">${avatarHtml}</div>
957
+ <div class="user-info">
958
+ <div class="user-name">${escapeHtml(u.display_name || u.username)}</div>
959
+ <div class="user-status-text"><span style="color:var(--success)">●</span> Online</div>
960
+ </div>`;
961
  DOM.onlineUsersList.appendChild(div);
962
  });
963
+ DOM.onlineTotal.textContent = `${State.onlineUsers.size} user${State.onlineUsers.size !== 1 ? 's' : ''} online`;
964
  }
965
 
966
  // =============================================
 
975
  function openSettings() {
976
  DOM.settingsDisplayName.value = State.user?.display_name || '';
977
  DOM.settingsUsername.value = State.user?.username || '';
978
+
979
  if (State.user?.avatar_path) {
980
+ DOM.settingsAvatarImg.src = fileUrl(State.user.avatar_path);
981
  DOM.settingsAvatarImg.classList.remove('hidden');
982
  DOM.settingsAvatarPreview.classList.add('hidden');
983
  } else {
 
985
  DOM.settingsAvatarPreview.classList.remove('hidden');
986
  }
987
 
 
988
  DOM.themeOptions.forEach(opt => {
989
  opt.classList.toggle('active', opt.dataset.theme === State.theme);
990
  });
 
1004
  async function saveSettings() {
1005
  const newName = DOM.settingsDisplayName.value.trim();
1006
  if (newName && newName !== State.user?.display_name) {
1007
+ try {
1008
+ const res = await fetch(`${API}/api/profile?display_name=${encodeURIComponent(newName)}`, {
1009
+ method: 'PATCH',
1010
+ headers: { 'X-Auth-Token': State.token }
1011
+ });
1012
+ if (res.ok) {
1013
+ State.user.display_name = newName;
1014
+ updateHeader();
1015
+ showToast('Display name updated', 'success');
1016
+ }
1017
+ } catch (e) {
1018
+ showToast('Failed to update name', 'error');
1019
+ }
1020
  }
1021
 
 
1022
  const avatarFile = DOM.avatarInput.files[0];
1023
  if (avatarFile) {
1024
  const formData = new FormData();
1025
  formData.append('file', avatarFile);
1026
+ try {
1027
+ const res = await fetch(`${API}/api/profile/avatar`, {
1028
+ method: 'POST',
1029
+ body: formData,
1030
+ headers: { 'X-Auth-Token': State.token }
1031
+ });
1032
+ if (res.ok) {
1033
+ const data = await res.json();
1034
+ State.user.avatar_path = data.avatar_path;
1035
+ DOM.settingsAvatarImg.src = fileUrl(data.avatar_path);
1036
+ DOM.settingsAvatarImg.classList.remove('hidden');
1037
+ DOM.settingsAvatarPreview.classList.add('hidden');
1038
+ updateHeader();
1039
+ showToast('Avatar updated', 'success');
1040
+ }
1041
+ } catch (e) {
1042
+ showToast('Failed to update avatar', 'error');
1043
  }
1044
  }
1045
 
 
1046
  const selectedTheme = document.querySelector('.theme-option.active')?.dataset.theme || 'light';
1047
+ if (selectedTheme === 'custom') {
1048
+ State.customColors.self = DOM.customSelfColor.value;
1049
+ State.customColors.other = DOM.customOtherColor.value;
1050
+ State.customColors.text = DOM.customTextColor.value;
1051
+ localStorage.setItem('custom-self', State.customColors.self);
1052
+ localStorage.setItem('custom-other', State.customColors.other);
1053
+ localStorage.setItem('custom-text', State.customColors.text);
 
 
 
1054
  }
1055
+ applyTheme(selectedTheme);
1056
 
1057
  DOM.settingsModal.classList.add('hidden');
1058
  }
1059
 
1060
+ DOM.customSelfColor.addEventListener('input', () => { DOM.selfColorHex.textContent = DOM.customSelfColor.value; });
1061
+ DOM.customOtherColor.addEventListener('input', () => { DOM.otherColorHex.textContent = DOM.customOtherColor.value; });
1062
+ DOM.customTextColor.addEventListener('input', () => { DOM.textColorHex.textContent = DOM.customTextColor.value; });
 
1063
 
 
1064
  DOM.themeOptions.forEach(opt => {
1065
  opt.addEventListener('click', () => {
1066
  DOM.themeOptions.forEach(o => o.classList.remove('active'));
 
1077
  });
1078
  });
1079
 
 
1080
  DOM.avatarUploadBtn.addEventListener('click', () => DOM.avatarInput.click());
1081
  DOM.avatarInput.addEventListener('change', () => {
1082
  const file = DOM.avatarInput.files[0];
 
1104
  }
1105
 
1106
  function escapeHtml(text) {
1107
+ if (!text) return '';
1108
  const div = document.createElement('div');
1109
  div.textContent = text;
1110
  return div.innerHTML;
1111
  }
1112
 
1113
  function formatTime(ms) {
1114
+ if (!ms) return '';
1115
  const date = new Date(ms);
1116
  const now = new Date();
1117
  const diff = now - date;
1118
+
1119
+ if (diff < 60000) return 'just now';
1120
+ if (diff < 3600000) {
1121
+ const mins = Math.floor(diff / 60000);
1122
+ return `${mins}m ago`;
1123
+ }
1124
  if (date.toDateString() === now.toDateString()) {
1125
  return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
1126
  }
1127
+ // Yesterday
1128
+ const yesterday = new Date(now);
1129
+ yesterday.setDate(yesterday.getDate() - 1);
1130
+ if (date.toDateString() === yesterday.toDateString()) {
1131
+ return 'Yesterday ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
1132
+ }
1133
  return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' +
1134
  date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
1135
  }
 
1161
  State.user = data.user;
1162
  showChat();
1163
  connectWebSocket();
1164
+ startTimestampUpdater();
1165
  return;
1166
  }
1167
  } catch (e) {}
1168
+ // Token invalid - clear it
1169
+ localStorage.removeItem('token');
1170
+ State.token = null;
1171
  }
1172
  showAuth();
1173
  }
 
1181
  DOM.authScreen.classList.remove('active');
1182
  DOM.chatScreen.classList.add('active');
1183
  updateHeader();
1184
+ startTimestampUpdater();
1185
  }
1186
 
1187
  function updateHeader() {
1188
+ if (!State.user) return;
1189
+ const displayEl = document.getElementById('header-display-name');
1190
+ if (displayEl) displayEl.textContent = State.user.display_name || State.user.username;
1191
+
1192
+ if (State.user.avatar_path) {
1193
+ const img = document.getElementById('header-avatar');
1194
+ const placeholder = document.getElementById('header-avatar-placeholder');
1195
+ if (img) {
1196
+ img.src = fileUrl(State.user.avatar_path);
1197
+ img.classList.remove('hidden');
1198
+ }
1199
+ if (placeholder) placeholder.classList.add('hidden');
1200
  }
1201
  }
1202
 
 
1213
  });
1214
  });
1215
 
1216
+ // Logout
1217
  document.getElementById('logout-btn').addEventListener('click', async () => {
1218
  if (State.token) {
1219
+ try {
1220
+ await fetch(`${API}/api/auth/logout`, {
1221
+ method: 'POST',
1222
+ headers: { 'X-Auth-Token': State.token }
1223
+ });
1224
+ } catch (e) {}
1225
  }
1226
+ if (State.ws) { State.ws.close(); State.ws = null; }
 
1227
  localStorage.removeItem('token');
1228
  State.token = null;
1229
  State.user = null;
1230
  State.messages.clear();
1231
  State.messageOrder = [];
1232
  State.onlineUsers.clear();
1233
+ State.typingUsers.clear();
1234
+ State.cursor = null;
1235
+ State.hasMore = true;
1236
  DOM.messagesList.innerHTML = '';
1237
  DOM.emptyState.style.display = 'flex';
1238
  showAuth();
1239
  updateOnlineBadge();
1240
  });
1241
 
1242
+ // Notification permission
1243
  if (window.Notification && Notification.permission === 'default') {
1244
  Notification.requestPermission();
1245
  }
1246
 
1247
+ // Expose public API for inline onclick handlers
1248
+ window.InfinityChat = { replyTo, editMessage, deleteMessage };
 
 
 
 
1249
 
1250
  init();
1251
  })();