smodusermc commited on
Commit
b5b2058
·
verified ·
1 Parent(s): e96e34e

Update static/script.js

Browse files
Files changed (1) hide show
  1. static/script.js +258 -207
static/script.js CHANGED
@@ -24,7 +24,13 @@
24
  self: localStorage.getItem('custom-self') || '#2563EB',
25
  other: localStorage.getItem('custom-other') || '#F3F4F6',
26
  text: localStorage.getItem('custom-text') || '#111827'
27
- }
 
 
 
 
 
 
28
  };
29
 
30
  // =============================================
@@ -51,6 +57,10 @@
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'),
@@ -86,6 +96,9 @@
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'),
@@ -95,8 +108,7 @@
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)}`;
@@ -110,17 +122,17 @@
110
  State.theme = theme;
111
  localStorage.setItem('theme', theme);
112
  if (theme === 'custom') {
113
- const selfColor = State.customColors.self;
114
- const otherColor = State.customColors.other;
115
- const textColor = State.customColors.text;
116
- document.documentElement.style.setProperty('--bubble-self', selfColor);
117
- document.documentElement.style.setProperty('--bubble-other', otherColor);
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');
@@ -132,7 +144,48 @@
132
  applyTheme(State.theme);
133
 
134
  // =============================================
135
- // Timestamp updater - refreshes every 30s
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  // =============================================
137
  function startTimestampUpdater() {
138
  setInterval(() => {
@@ -143,19 +196,19 @@
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
  }
156
 
157
  // =============================================
158
- // Authentication
159
  // =============================================
160
  DOM.authTabs.forEach(tab => {
161
  tab.addEventListener('click', () => {
@@ -170,9 +223,10 @@
170
 
171
  DOM.loginForm.addEventListener('submit', async (e) => {
172
  e.preventDefault();
173
- const username = document.getElementById('login-username').value.trim();
174
- const password = document.getElementById('login-password').value;
175
- await login(username, password);
 
176
  });
177
 
178
  DOM.signupForm.addEventListener('submit', async (e) => {
@@ -236,29 +290,19 @@
236
  function connectWebSocket() {
237
  if (!State.token) return;
238
  if (State.ws && State.ws.readyState === WebSocket.OPEN) 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
- State.ws = new WebSocket(url);
246
-
247
  State.ws.onopen = () => {
248
  State.wsState = 'connected';
249
  updateConnectionUI();
250
  loadMessages();
251
  requestOnlineUsers();
252
  };
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
- }
260
  };
261
-
262
  State.ws.onclose = () => {
263
  if (State.wsState === 'connected') {
264
  State.wsState = 'reconnecting';
@@ -269,7 +313,6 @@
269
  updateConnectionUI();
270
  }
271
  };
272
-
273
  State.ws.onerror = () => {
274
  State.wsState = 'disconnected';
275
  updateConnectionUI();
@@ -277,21 +320,16 @@
277
  }
278
 
279
  function updateConnectionUI() {
280
- const status = DOM.connectionStatus;
281
- status.className = 'connection-status';
282
- switch (State.wsState) {
283
- case 'connected':
284
- status.classList.add('connected');
285
- DOM.connectionText.textContent = 'Connected';
286
- break;
287
- case 'reconnecting':
288
- status.classList.add('reconnecting');
289
- DOM.connectionText.textContent = 'Reconnecting...';
290
- break;
291
- default:
292
- status.classList.add('disconnected');
293
- DOM.connectionText.textContent = 'Disconnected';
294
- }
295
  }
296
 
297
  function handleWSMessage(msg) {
@@ -311,20 +349,27 @@
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',
@@ -334,13 +379,12 @@
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;
@@ -348,6 +392,7 @@
348
  renderAllMessages();
349
  DOM.loadOlderBtn.classList.toggle('hidden', !msg.has_more);
350
  break;
 
351
 
352
  case 'user_status':
353
  if (msg.status === 'online') {
@@ -364,11 +409,8 @@
364
  break;
365
 
366
  case 'typing_indicator':
367
- if (msg.is_typing) {
368
- State.typingUsers.set(msg.user_id, msg);
369
- } else {
370
- State.typingUsers.delete(msg.user_id);
371
- }
372
  updateTypingIndicator();
373
  break;
374
 
@@ -411,16 +453,86 @@
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
  // =============================================
@@ -447,15 +559,13 @@
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) {
@@ -464,16 +574,12 @@
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
  }
@@ -497,27 +603,33 @@
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
  }
@@ -527,37 +639,29 @@
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
 
@@ -572,33 +676,24 @@
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">
@@ -619,7 +714,6 @@
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
 
@@ -629,39 +723,22 @@
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() {
651
- DOM.messagesScroll.scrollTop = DOM.messagesScroll.scrollHeight;
652
- }
653
-
654
- // =============================================
655
- // Scroll detection
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
  // =============================================
666
  // Compose
667
  // =============================================
@@ -670,7 +747,6 @@
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();
@@ -692,21 +768,18 @@
692
  showToast('Not connected', 'warning');
693
  return;
694
  }
695
-
696
  const payload = {
697
  type: 'send_message',
698
  content: content,
699
  reply_to_id: State.replyTo ? State.replyTo.id : null,
700
  client_id: generateId()
701
  };
702
-
703
  if (State.pendingFile) {
704
  payload.file_path = State.pendingFile.file_path;
705
  payload.file_type = State.pendingFile.file_type;
706
  payload.file_name = State.pendingFile.file_name;
707
  payload.file_size = State.pendingFile.file_size;
708
  }
709
-
710
  State.ws.send(JSON.stringify(payload));
711
  DOM.messageInput.value = '';
712
  DOM.sendBtn.disabled = true;
@@ -729,7 +802,8 @@
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
 
@@ -752,9 +826,7 @@
752
  }
753
 
754
  document.addEventListener('click', (e) => {
755
- if (e.target.id === 'cancel-reply' || e.target.closest('#cancel-reply')) {
756
- clearReply();
757
- }
758
  });
759
 
760
  // =============================================
@@ -764,23 +836,19 @@
764
  const msg = State.messages.get(messageId);
765
  if (!msg) return;
766
  const newContent = prompt('Edit message:', msg.content);
767
- if (newContent !== null && newContent.trim() !== '') {
768
- if (State.ws?.readyState === WebSocket.OPEN) {
769
- State.ws.send(JSON.stringify({ type: 'edit_message', message_id: messageId, content: newContent.trim() }));
770
- }
771
  }
772
  }
773
 
774
  function deleteMessage(messageId) {
775
  if (confirm('Permanently delete this message?')) {
776
- if (State.ws?.readyState === WebSocket.OPEN) {
777
- State.ws.send(JSON.stringify({ type: 'delete_message', message_id: messageId }));
778
- }
779
  }
780
  }
781
 
782
  // =============================================
783
- // File Upload (Chunked)
784
  // =============================================
785
  DOM.attachBtn.addEventListener('click', () => DOM.fileInput.click());
786
  DOM.fileInput.addEventListener('change', async (e) => {
@@ -794,29 +862,21 @@
794
  const CHUNK_SIZE = 5 * 1024 * 1024;
795
  const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
796
  const uploadId = generateId();
797
-
798
  DOM.uploadProgress.classList.remove('hidden');
799
  updateProgress(0, file.name);
800
 
801
  for (let i = 0; i < totalChunks; i++) {
802
- const start = i * CHUNK_SIZE;
803
- const end = Math.min(start + CHUNK_SIZE, file.size);
804
- const blob = file.slice(start, end);
805
  const formData = new FormData();
806
  formData.append('file', blob);
807
-
808
- const url = `${API}/api/upload/chunk?chunk_index=${i}&total_chunks=${totalChunks}&file_name=${encodeURIComponent(file.name)}&file_type=${encodeURIComponent(file.type || 'application/octet-stream')}&file_size=${file.size}&upload_id=${uploadId}`;
809
-
810
  try {
811
- const res = await fetch(url, {
812
- method: 'POST',
813
- body: formData,
814
- headers: { 'X-Auth-Token': State.token }
815
- });
816
  if (!res.ok) throw new Error('Upload failed');
817
  const data = await res.json();
818
  updateProgress(((i + 1) / totalChunks) * 100, file.name);
819
-
820
  if (data.status === 'complete') {
821
  State.pendingFile = {
822
  file_path: data.file_path,
@@ -865,9 +925,7 @@
865
  DOM.emojiBtn.addEventListener('click', (e) => {
866
  e.stopPropagation();
867
  DOM.emojiPicker.classList.toggle('hidden');
868
- if (!DOM.emojiPicker.classList.contains('hidden')) {
869
- renderEmojis('smileys');
870
- }
871
  });
872
 
873
  document.addEventListener('click', (e) => {
@@ -910,17 +968,17 @@
910
  }
911
 
912
  // =============================================
913
- // Load More Messages
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
  });
921
 
922
  // =============================================
923
- // Online Users Modal
924
  // =============================================
925
  DOM.onlineBtn.addEventListener('click', () => {
926
  renderOnlineUsers();
@@ -949,8 +1007,7 @@
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>
@@ -964,7 +1021,7 @@
964
  }
965
 
966
  // =============================================
967
- // Settings Modal
968
  // =============================================
969
  document.getElementById('settings-btn').addEventListener('click', openSettings);
970
  document.querySelectorAll('[data-close="settings-modal"]').forEach(btn => {
@@ -998,6 +1055,9 @@
998
  DOM.textColorHex.textContent = State.customColors.text;
999
  }
1000
 
 
 
 
1001
  DOM.settingsModal.classList.remove('hidden');
1002
  }
1003
 
@@ -1043,6 +1103,12 @@
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;
@@ -1115,16 +1181,11 @@
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()) {
@@ -1148,9 +1209,10 @@
1148
  }
1149
 
1150
  // =============================================
1151
- // Initialization
1152
  // =============================================
1153
  async function init() {
 
1154
  if (State.token) {
1155
  try {
1156
  const res = await fetch(`${API}/api/auth/verify`, {
@@ -1165,7 +1227,6 @@
1165
  return;
1166
  }
1167
  } catch (e) {}
1168
- // Token invalid - clear it
1169
  localStorage.removeItem('token');
1170
  State.token = null;
1171
  }
@@ -1188,23 +1249,17 @@
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
 
1203
- // Password visibility toggle
1204
  document.querySelectorAll('.input-toggle').forEach(btn => {
1205
  btn.addEventListener('click', () => {
1206
- const targetId = btn.dataset.toggle;
1207
- const input = document.getElementById(targetId);
1208
  if (input) {
1209
  input.type = input.type === 'password' ? 'text' : 'password';
1210
  const icon = btn.querySelector('i');
@@ -1213,14 +1268,10 @@
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; }
@@ -1233,18 +1284,18 @@
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();
 
24
  self: localStorage.getItem('custom-self') || '#2563EB',
25
  other: localStorage.getItem('custom-other') || '#F3F4F6',
26
  text: localStorage.getItem('custom-text') || '#111827'
27
+ },
28
+ prefs: {
29
+ autoScroll: localStorage.getItem('pref-autoscroll') !== 'false',
30
+ showScrollBtn: localStorage.getItem('pref-scrollbtn') !== 'false',
31
+ showLatestBar: localStorage.getItem('pref-latestbar') !== 'false'
32
+ },
33
+ latestUnseen: null
34
  };
35
 
36
  // =============================================
 
57
  emptyState: document.getElementById('empty-state'),
58
  loadOlderBtn: document.getElementById('load-older-btn'),
59
  scrollBottomBtn: document.getElementById('scroll-bottom-btn'),
60
+ latestMessageBar: document.getElementById('latest-message-bar'),
61
+ lmbAvatar: document.getElementById('lmb-avatar'),
62
+ lmbName: document.getElementById('lmb-name'),
63
+ lmbText: document.getElementById('lmb-text'),
64
  typingIndicator: document.getElementById('typing-indicator'),
65
  typingText: document.getElementById('typing-text'),
66
  replyPreview: document.getElementById('reply-preview'),
 
96
  selfColorHex: document.getElementById('self-color-hex'),
97
  otherColorHex: document.getElementById('other-color-hex'),
98
  textColorHex: document.getElementById('text-color-hex'),
99
+ toggleAutoScroll: document.getElementById('toggle-autoscroll'),
100
+ toggleScrollBtn: document.getElementById('toggle-scroll-btn'),
101
+ toggleLatestBar: document.getElementById('toggle-latest-bar'),
102
  onlineBtn: document.getElementById('online-btn'),
103
  onlineUsersList: document.getElementById('online-users-list'),
104
  onlineTotal: document.getElementById('online-total'),
 
108
  const API = window.location.origin;
109
 
110
  // =============================================
111
+ // File URL helper
 
112
  // =============================================
113
  function fileUrl(filePath) {
114
  return `${API}/api/download/${encodeURIComponent(filePath)}?t=${encodeURIComponent(State.token)}`;
 
122
  State.theme = theme;
123
  localStorage.setItem('theme', theme);
124
  if (theme === 'custom') {
125
+ const s = State.customColors.self;
126
+ const o = State.customColors.other;
127
+ const t = State.customColors.text;
128
+ document.documentElement.style.setProperty('--bubble-self', s);
129
+ document.documentElement.style.setProperty('--bubble-other', o);
130
+ document.documentElement.style.setProperty('--bubble-other-text', t);
131
  const isLight = (c) => {
132
  const r = parseInt(c.slice(1,3),16), g = parseInt(c.slice(3,5),16), b = parseInt(c.slice(5,7),16);
133
  return (0.299*r + 0.587*g + 0.114*b) > 150;
134
  };
135
+ document.documentElement.style.setProperty('--bubble-self-text', isLight(s) ? '#111827' : '#FFFFFF');
136
  } else {
137
  document.documentElement.style.removeProperty('--bubble-self');
138
  document.documentElement.style.removeProperty('--bubble-other');
 
144
  applyTheme(State.theme);
145
 
146
  // =============================================
147
+ // Preferences
148
+ // =============================================
149
+ function applyPrefs() {
150
+ if (DOM.toggleAutoScroll) DOM.toggleAutoScroll.checked = State.prefs.autoScroll;
151
+ if (DOM.toggleScrollBtn) DOM.toggleScrollBtn.checked = State.prefs.showScrollBtn;
152
+ if (DOM.toggleLatestBar) DOM.toggleLatestBar.checked = State.prefs.showLatestBar;
153
+ updateScrollUI();
154
+ }
155
+
156
+ function savePrefs() {
157
+ localStorage.setItem('pref-autoscroll', State.prefs.autoScroll);
158
+ localStorage.setItem('pref-scrollbtn', State.prefs.showScrollBtn);
159
+ localStorage.setItem('pref-latestbar', State.prefs.showLatestBar);
160
+ }
161
+
162
+ if (DOM.toggleAutoScroll) {
163
+ DOM.toggleAutoScroll.addEventListener('change', () => {
164
+ State.prefs.autoScroll = DOM.toggleAutoScroll.checked;
165
+ savePrefs();
166
+ if (State.prefs.autoScroll) {
167
+ scrollToBottom();
168
+ hideLatestBar();
169
+ }
170
+ });
171
+ }
172
+ if (DOM.toggleScrollBtn) {
173
+ DOM.toggleScrollBtn.addEventListener('change', () => {
174
+ State.prefs.showScrollBtn = DOM.toggleScrollBtn.checked;
175
+ savePrefs();
176
+ updateScrollUI();
177
+ });
178
+ }
179
+ if (DOM.toggleLatestBar) {
180
+ DOM.toggleLatestBar.addEventListener('change', () => {
181
+ State.prefs.showLatestBar = DOM.toggleLatestBar.checked;
182
+ savePrefs();
183
+ if (!State.prefs.showLatestBar) hideLatestBar();
184
+ });
185
+ }
186
+
187
+ // =============================================
188
+ // Timestamp updater
189
  // =============================================
190
  function startTimestampUpdater() {
191
  setInterval(() => {
 
196
  }
197
 
198
  // =============================================
199
+ // Toast
200
  // =============================================
201
  function showToast(message, type = 'info') {
202
  const toast = document.createElement('div');
203
  toast.className = `toast toast-${type}`;
204
+ const icons = { success:'check-circle', error:'exclamation-circle', warning:'exclamation-triangle', info:'info-circle' };
205
+ toast.innerHTML = `<i class="fas fa-${icons[type]||'info-circle'}"></i><span>${message}</span>`;
206
  DOM.toastContainer.appendChild(toast);
207
  setTimeout(() => toast.remove(), 4000);
208
  }
209
 
210
  // =============================================
211
+ // Auth
212
  // =============================================
213
  DOM.authTabs.forEach(tab => {
214
  tab.addEventListener('click', () => {
 
223
 
224
  DOM.loginForm.addEventListener('submit', async (e) => {
225
  e.preventDefault();
226
+ await login(
227
+ document.getElementById('login-username').value.trim(),
228
+ document.getElementById('login-password').value
229
+ );
230
  });
231
 
232
  DOM.signupForm.addEventListener('submit', async (e) => {
 
290
  function connectWebSocket() {
291
  if (!State.token) return;
292
  if (State.ws && State.ws.readyState === WebSocket.OPEN) return;
 
293
  State.wsState = 'connecting';
294
  updateConnectionUI();
 
295
  const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
296
+ State.ws = new WebSocket(`${proto}//${location.host}/ws?token=${encodeURIComponent(State.token)}`);
 
 
297
  State.ws.onopen = () => {
298
  State.wsState = 'connected';
299
  updateConnectionUI();
300
  loadMessages();
301
  requestOnlineUsers();
302
  };
303
+ State.ws.onmessage = (e) => {
304
+ try { handleWSMessage(JSON.parse(e.data)); } catch(err) { console.error('WS parse error', err); }
 
 
 
 
 
305
  };
 
306
  State.ws.onclose = () => {
307
  if (State.wsState === 'connected') {
308
  State.wsState = 'reconnecting';
 
313
  updateConnectionUI();
314
  }
315
  };
 
316
  State.ws.onerror = () => {
317
  State.wsState = 'disconnected';
318
  updateConnectionUI();
 
320
  }
321
 
322
  function updateConnectionUI() {
323
+ DOM.connectionStatus.className = 'connection-status';
324
+ const map = {
325
+ connected: ['connected', 'Connected'],
326
+ reconnecting: ['reconnecting', 'Reconnecting...'],
327
+ disconnected: ['disconnected', 'Disconnected'],
328
+ connecting: ['reconnecting', 'Connecting...']
329
+ };
330
+ const [cls, txt] = map[State.wsState] || ['disconnected', 'Disconnected'];
331
+ DOM.connectionStatus.classList.add(cls);
332
+ DOM.connectionText.textContent = txt;
 
 
 
 
 
333
  }
334
 
335
  function handleWSMessage(msg) {
 
349
 
350
  case 'new_message': {
351
  const m = msg.message;
 
352
  if (State.messages.has(m.id)) {
353
+ const ex = State.messages.get(m.id);
354
+ ex.status = m.status;
 
355
  updateMessageBubble(m.id);
356
  return;
357
  }
358
  State.messages.set(m.id, m);
359
+ insertMessageIdSorted(m.id);
 
360
  renderMessage(m, true);
361
+
362
+ if (State.isAtBottom) {
363
+ if (State.prefs.autoScroll) scrollToBottom();
364
+ } else {
365
+ // Show latest message bar if not at bottom
366
+ State.latestUnseen = m;
367
+ showLatestBar(m);
368
+ }
369
+
370
+ if (document.visibilityState === 'visible' && State.isAtBottom) {
371
+ sendMarkRead(m.id);
372
+ }
373
  if (document.visibilityState !== 'visible' && Notification.permission === 'granted') {
374
  new Notification(m.sender_display_name || m.sender_username, {
375
  body: m.content || 'Sent a file',
 
379
  break;
380
  }
381
 
382
+ case 'messages_loaded': {
 
383
  const sorted = [...msg.messages].sort((a, b) => a.id - b.id);
384
  sorted.forEach(m => {
385
  if (!State.messages.has(m.id)) {
386
  State.messages.set(m.id, m);
387
+ insertMessageIdSorted(m.id);
388
  }
389
  });
390
  State.hasMore = msg.has_more;
 
392
  renderAllMessages();
393
  DOM.loadOlderBtn.classList.toggle('hidden', !msg.has_more);
394
  break;
395
+ }
396
 
397
  case 'user_status':
398
  if (msg.status === 'online') {
 
409
  break;
410
 
411
  case 'typing_indicator':
412
+ if (msg.is_typing) State.typingUsers.set(msg.user_id, msg);
413
+ else State.typingUsers.delete(msg.user_id);
 
 
 
414
  updateTypingIndicator();
415
  break;
416
 
 
453
  }
454
  }
455
 
456
+ function insertMessageIdSorted(id) {
 
 
457
  let lo = 0, hi = State.messageOrder.length;
458
  while (lo < hi) {
459
  const mid = (lo + hi) >> 1;
460
  if (State.messageOrder[mid] < id) lo = mid + 1;
461
  else hi = mid;
462
  }
463
+ if (State.messageOrder[lo] !== id) {
464
+ State.messageOrder.splice(lo, 0, id);
465
+ }
466
+ }
467
+
468
+ // =============================================
469
+ // Latest message bar
470
+ // =============================================
471
+ function showLatestBar(msg) {
472
+ if (!State.prefs.showLatestBar || !DOM.latestMessageBar) return;
473
+ const name = msg.sender_display_name || msg.sender_username || 'Unknown';
474
+ const text = msg.content || (msg.file_name ? '📎 ' + msg.file_name : '📎 File');
475
+
476
+ // Avatar
477
+ if (msg.sender_avatar_path) {
478
+ DOM.lmbAvatar.innerHTML = `<img src="${fileUrl(msg.sender_avatar_path)}" alt="${escapeHtml(name)}">`;
479
+ } else {
480
+ DOM.lmbAvatar.textContent = name.charAt(0).toUpperCase();
481
+ DOM.lmbAvatar.style.background = 'var(--primary)';
482
+ }
483
+
484
+ DOM.lmbName.textContent = name + ':';
485
+ DOM.lmbText.textContent = truncate(text, 40);
486
+ DOM.latestMessageBar.classList.remove('hidden');
487
+ }
488
+
489
+ function hideLatestBar() {
490
+ if (DOM.latestMessageBar) DOM.latestMessageBar.classList.add('hidden');
491
+ State.latestUnseen = null;
492
+ }
493
+
494
+ if (DOM.latestMessageBar) {
495
+ DOM.latestMessageBar.addEventListener('click', () => {
496
+ scrollToBottom();
497
+ hideLatestBar();
498
+ });
499
+ }
500
+
501
+ // =============================================
502
+ // Scroll UI
503
+ // =============================================
504
+ function updateScrollUI() {
505
+ if (!State.isAtBottom) {
506
+ if (State.prefs.showScrollBtn) {
507
+ DOM.scrollBottomBtn.classList.remove('hidden');
508
+ } else {
509
+ DOM.scrollBottomBtn.classList.add('hidden');
510
+ }
511
+ } else {
512
+ DOM.scrollBottomBtn.classList.add('hidden');
513
+ hideLatestBar();
514
+ }
515
+ }
516
+
517
+ DOM.messagesScroll.addEventListener('scroll', () => {
518
+ const { scrollTop, scrollHeight, clientHeight } = DOM.messagesScroll;
519
+ State.isAtBottom = (scrollHeight - scrollTop - clientHeight) < 80;
520
+ updateScrollUI();
521
+ if (State.isAtBottom) {
522
+ hideLatestBar();
523
+ if (State.latestUnseen) sendMarkRead(State.latestUnseen.id);
524
+ }
525
+ });
526
+
527
+ DOM.scrollBottomBtn.addEventListener('click', () => {
528
+ scrollToBottom();
529
+ hideLatestBar();
530
+ });
531
+
532
+ function scrollToBottom() {
533
+ DOM.messagesScroll.scrollTop = DOM.messagesScroll.scrollHeight;
534
+ State.isAtBottom = true;
535
+ DOM.scrollBottomBtn.classList.add('hidden');
536
  }
537
 
538
  // =============================================
 
559
  // =============================================
560
  // Rendering
561
  // =============================================
 
 
562
  function buildSenderAvatarHTML(msg) {
563
+ const name = escapeHtml(msg.sender_display_name || msg.sender_username || '?');
564
+ const initial = (msg.sender_display_name || msg.sender_username || '?').charAt(0).toUpperCase();
565
  if (msg.sender_avatar_path) {
566
+ return `<img class="msg-avatar" src="${fileUrl(msg.sender_avatar_path)}" alt="${name}" title="${name}">`;
 
567
  }
568
+ return `<div class="msg-avatar-placeholder" title="${name}">${initial}</div>`;
 
569
  }
570
 
571
  function renderMessage(msg, append = false) {
 
574
  if (append) {
575
  DOM.messagesList.appendChild(wrapper);
576
  } else {
 
577
  const ids = State.messageOrder;
578
  const idx = ids.indexOf(msg.id);
579
  const nextId = ids[idx + 1];
580
  if (nextId) {
581
  const nextEl = DOM.messagesList.querySelector(`[data-id="${nextId}"]`);
582
+ if (nextEl) { DOM.messagesList.insertBefore(wrapper, nextEl); return; }
 
 
 
583
  }
584
  DOM.messagesList.appendChild(wrapper);
585
  }
 
603
  const displayName = escapeHtml(msg.sender_display_name || msg.sender_username || 'Unknown');
604
  let html = '';
605
 
606
+ // Sender row: avatar + name, always shown
607
  if (!isSelf) {
608
+ html += `<div class="msg-sender-row">
609
+ ${buildSenderAvatarHTML(msg)}
610
+ <span class="msg-sender-name">${displayName}</span>
611
+ </div>`;
612
+ } else {
613
+ html += `<div class="msg-sender-row msg-sender-row-self">
614
+ <span class="msg-sender-name">${displayName}</span>
615
+ ${buildSenderAvatarHTML(msg)}
616
+ </div>`;
617
  }
618
 
619
  // Reply indicator
620
  if (msg.reply_to_id && State.messages.has(msg.reply_to_id)) {
621
  const reply = State.messages.get(msg.reply_to_id);
622
+ const rName = escapeHtml(reply.sender_display_name || reply.sender_username || 'Unknown');
623
+ const rText = escapeHtml(truncate(reply.content || (reply.file_name ? '📎 ' + reply.file_name : ''), 60));
624
  html += `<div class="reply-indicator">
625
  <i class="fas fa-reply"></i>
626
+ <span><strong>${rName}:</strong> ${rText}</span>
627
  </div>`;
628
  }
629
 
630
  html += `<div class="message-bubble">`;
631
 
632
+ // Text content
633
  if (msg.content) {
634
  html += `<div class="message-text">${escapeHtml(msg.content)}</div>`;
635
  }
 
639
  html += buildAttachmentHTML(msg);
640
  }
641
 
642
+ // Meta
643
+ html += `<div class="message-meta">
644
+ <span class="message-time" data-ts="${msg.timestamp_ms}">${formatTime(msg.timestamp_ms)}</span>`;
645
  if (msg.is_edited) html += ` <span class="edited-label">edited</span>`;
646
  if (isSelf) {
647
  const isRead = msg.status === 'read';
648
+ html += `<span class="message-status${isRead ? ' read' : ''}">
649
+ <i class="fas fa-check${isRead ? '-double' : ''}"></i>
650
+ </span>`;
651
  }
652
  html += `</div>`;
653
 
654
+ // Actions - always visible
655
+ html += `<div class="message-actions">
656
+ <button onclick="InfinityChat.replyTo(${msg.id})" title="Reply"><i class="fas fa-reply"></i></button>`;
657
  if (isSelf) {
658
+ html += `<button onclick="InfinityChat.editMessage(${msg.id})" title="Edit"><i class="fas fa-pen"></i></button>
659
+ <button onclick="InfinityChat.deleteMessage(${msg.id})" title="Delete"><i class="fas fa-trash"></i></button>`;
660
  }
661
  html += `</div>`;
662
 
663
  html += `</div>`; // close message-bubble
664
 
 
 
 
 
 
 
 
 
665
  return html;
666
  }
667
 
 
676
  alt="${fname}"
677
  loading="lazy"
678
  onclick="window.open('${url}', '_blank')"
679
+ onerror="this.parentElement.innerHTML='<div class=\\'attachment-file\\' onclick=\\'window.open(\\'${url}\\',\\'_blank\\')\\'>'+
680
+ '<i class=\\'fas fa-image\\'></i>'+
681
+ '<div class=\\'file-info\\'>'+
682
+ '<div class=\\'file-name\\'>${fname}</div>'+
683
+ '<div class=\\'file-size\\'>${formatFileSize(msg.file_size)}</div>'+
684
+ '</div></div>'">
 
 
685
  </div>`;
686
  }
 
687
  if (ft.startsWith('video/')) {
688
  return `<div class="attachment">
689
+ <video controls preload="metadata">
690
  <source src="${url}" type="${escapeHtml(msg.file_type)}">
 
691
  </video>
692
  </div>`;
693
  }
 
694
  if (ft.startsWith('audio/')) {
695
+ return `<div class="attachment"><audio controls src="${url}"></audio></div>`;
 
 
696
  }
 
 
697
  return `<div class="attachment-file" onclick="window.open('${url}', '_blank')">
698
  <i class="fas fa-file-alt"></i>
699
  <div class="file-info">
 
714
  }
715
 
716
  function renderAllMessages() {
 
717
  const wasAtBottom = State.isAtBottom;
718
  const prevScrollHeight = DOM.messagesScroll.scrollHeight;
719
 
 
723
  DOM.emptyState.style.display = 'flex';
724
  return;
725
  }
 
726
  DOM.emptyState.style.display = 'none';
727
 
 
728
  State.messageOrder.forEach(id => {
729
  const msg = State.messages.get(id);
730
  if (msg) renderMessage(msg, true);
731
  });
732
 
733
+ if (wasAtBottom && State.prefs.autoScroll) {
734
  scrollToBottom();
735
+ } else if (!wasAtBottom) {
736
+ DOM.messagesScroll.scrollTop += (DOM.messagesScroll.scrollHeight - prevScrollHeight);
737
  } else {
738
+ scrollToBottom();
 
 
739
  }
740
  }
741
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
742
  // =============================================
743
  // Compose
744
  // =============================================
 
747
  sendMessage();
748
  });
749
 
 
750
  DOM.messageInput.addEventListener('keydown', (e) => {
751
  if (e.key === 'Enter' && !e.shiftKey) {
752
  e.preventDefault();
 
768
  showToast('Not connected', 'warning');
769
  return;
770
  }
 
771
  const payload = {
772
  type: 'send_message',
773
  content: content,
774
  reply_to_id: State.replyTo ? State.replyTo.id : null,
775
  client_id: generateId()
776
  };
 
777
  if (State.pendingFile) {
778
  payload.file_path = State.pendingFile.file_path;
779
  payload.file_type = State.pendingFile.file_type;
780
  payload.file_name = State.pendingFile.file_name;
781
  payload.file_size = State.pendingFile.file_size;
782
  }
 
783
  State.ws.send(JSON.stringify(payload));
784
  DOM.messageInput.value = '';
785
  DOM.sendBtn.disabled = true;
 
802
  const users = Array.from(State.typingUsers.values()).filter(u => u.user_id !== State.user?.id);
803
  DOM.typingIndicator.classList.toggle('hidden', users.length === 0);
804
  if (users.length > 0) {
805
+ DOM.typingText.textContent = users.map(u => u.display_name || u.username).join(', ') +
806
+ (users.length === 1 ? ' is typing...' : ' are typing...');
807
  }
808
  }
809
 
 
826
  }
827
 
828
  document.addEventListener('click', (e) => {
829
+ if (e.target.id === 'cancel-reply' || e.target.closest('#cancel-reply')) clearReply();
 
 
830
  });
831
 
832
  // =============================================
 
836
  const msg = State.messages.get(messageId);
837
  if (!msg) return;
838
  const newContent = prompt('Edit message:', msg.content);
839
+ if (newContent !== null && newContent.trim()) {
840
+ State.ws?.send(JSON.stringify({ type: 'edit_message', message_id: messageId, content: newContent.trim() }));
 
 
841
  }
842
  }
843
 
844
  function deleteMessage(messageId) {
845
  if (confirm('Permanently delete this message?')) {
846
+ State.ws?.send(JSON.stringify({ type: 'delete_message', message_id: messageId }));
 
 
847
  }
848
  }
849
 
850
  // =============================================
851
+ // File Upload
852
  // =============================================
853
  DOM.attachBtn.addEventListener('click', () => DOM.fileInput.click());
854
  DOM.fileInput.addEventListener('change', async (e) => {
 
862
  const CHUNK_SIZE = 5 * 1024 * 1024;
863
  const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
864
  const uploadId = generateId();
 
865
  DOM.uploadProgress.classList.remove('hidden');
866
  updateProgress(0, file.name);
867
 
868
  for (let i = 0; i < totalChunks; i++) {
869
+ const blob = file.slice(i * CHUNK_SIZE, Math.min((i + 1) * CHUNK_SIZE, file.size));
 
 
870
  const formData = new FormData();
871
  formData.append('file', blob);
872
+ const url = `${API}/api/upload/chunk?chunk_index=${i}&total_chunks=${totalChunks}` +
873
+ `&file_name=${encodeURIComponent(file.name)}&file_type=${encodeURIComponent(file.type || 'application/octet-stream')}` +
874
+ `&file_size=${file.size}&upload_id=${uploadId}`;
875
  try {
876
+ const res = await fetch(url, { method: 'POST', body: formData, headers: { 'X-Auth-Token': State.token } });
 
 
 
 
877
  if (!res.ok) throw new Error('Upload failed');
878
  const data = await res.json();
879
  updateProgress(((i + 1) / totalChunks) * 100, file.name);
 
880
  if (data.status === 'complete') {
881
  State.pendingFile = {
882
  file_path: data.file_path,
 
925
  DOM.emojiBtn.addEventListener('click', (e) => {
926
  e.stopPropagation();
927
  DOM.emojiPicker.classList.toggle('hidden');
928
+ if (!DOM.emojiPicker.classList.contains('hidden')) renderEmojis('smileys');
 
 
929
  });
930
 
931
  document.addEventListener('click', (e) => {
 
968
  }
969
 
970
  // =============================================
971
+ // Load More
972
  // =============================================
973
  DOM.loadOlderBtn.addEventListener('click', () => {
974
  if (State.ws?.readyState === WebSocket.OPEN && State.hasMore && State.cursor) {
975
+ State.isAtBottom = false;
976
  State.ws.send(JSON.stringify({ type: 'load_messages', cursor: State.cursor, limit: 50 }));
977
  }
978
  });
979
 
980
  // =============================================
981
+ // Online Users
982
  // =============================================
983
  DOM.onlineBtn.addEventListener('click', () => {
984
  renderOnlineUsers();
 
1007
  if (u.avatar_path) {
1008
  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%">`;
1009
  } else {
1010
+ avatarHtml = (u.display_name || u.username || '?').charAt(0).toUpperCase();
 
1011
  }
1012
  div.innerHTML = `
1013
  <div class="user-avatar">${avatarHtml}</div>
 
1021
  }
1022
 
1023
  // =============================================
1024
+ // Settings
1025
  // =============================================
1026
  document.getElementById('settings-btn').addEventListener('click', openSettings);
1027
  document.querySelectorAll('[data-close="settings-modal"]').forEach(btn => {
 
1055
  DOM.textColorHex.textContent = State.customColors.text;
1056
  }
1057
 
1058
+ // Load prefs into toggles
1059
+ applyPrefs();
1060
+
1061
  DOM.settingsModal.classList.remove('hidden');
1062
  }
1063
 
 
1103
  }
1104
  }
1105
 
1106
+ // Save prefs from toggles
1107
+ if (DOM.toggleAutoScroll) State.prefs.autoScroll = DOM.toggleAutoScroll.checked;
1108
+ if (DOM.toggleScrollBtn) State.prefs.showScrollBtn = DOM.toggleScrollBtn.checked;
1109
+ if (DOM.toggleLatestBar) State.prefs.showLatestBar = DOM.toggleLatestBar.checked;
1110
+ savePrefs();
1111
+
1112
  const selectedTheme = document.querySelector('.theme-option.active')?.dataset.theme || 'light';
1113
  if (selectedTheme === 'custom') {
1114
  State.customColors.self = DOM.customSelfColor.value;
 
1181
  const date = new Date(ms);
1182
  const now = new Date();
1183
  const diff = now - date;
 
1184
  if (diff < 60000) return 'just now';
1185
+ if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
 
 
 
1186
  if (date.toDateString() === now.toDateString()) {
1187
  return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
1188
  }
 
1189
  const yesterday = new Date(now);
1190
  yesterday.setDate(yesterday.getDate() - 1);
1191
  if (date.toDateString() === yesterday.toDateString()) {
 
1209
  }
1210
 
1211
  // =============================================
1212
+ // Init
1213
  // =============================================
1214
  async function init() {
1215
+ applyPrefs();
1216
  if (State.token) {
1217
  try {
1218
  const res = await fetch(`${API}/api/auth/verify`, {
 
1227
  return;
1228
  }
1229
  } catch (e) {}
 
1230
  localStorage.removeItem('token');
1231
  State.token = null;
1232
  }
 
1249
  if (!State.user) return;
1250
  const displayEl = document.getElementById('header-display-name');
1251
  if (displayEl) displayEl.textContent = State.user.display_name || State.user.username;
 
1252
  if (State.user.avatar_path) {
1253
  const img = document.getElementById('header-avatar');
1254
+ const ph = document.getElementById('header-avatar-placeholder');
1255
+ if (img) { img.src = fileUrl(State.user.avatar_path); img.classList.remove('hidden'); }
1256
+ if (ph) ph.classList.add('hidden');
 
 
 
1257
  }
1258
  }
1259
 
 
1260
  document.querySelectorAll('.input-toggle').forEach(btn => {
1261
  btn.addEventListener('click', () => {
1262
+ const input = document.getElementById(btn.dataset.toggle);
 
1263
  if (input) {
1264
  input.type = input.type === 'password' ? 'text' : 'password';
1265
  const icon = btn.querySelector('i');
 
1268
  });
1269
  });
1270
 
 
1271
  document.getElementById('logout-btn').addEventListener('click', async () => {
1272
  if (State.token) {
1273
  try {
1274
+ await fetch(`${API}/api/auth/logout`, { method: 'POST', headers: { 'X-Auth-Token': State.token } });
 
 
 
1275
  } catch (e) {}
1276
  }
1277
  if (State.ws) { State.ws.close(); State.ws = null; }
 
1284
  State.typingUsers.clear();
1285
  State.cursor = null;
1286
  State.hasMore = true;
1287
+ State.latestUnseen = null;
1288
  DOM.messagesList.innerHTML = '';
1289
  DOM.emptyState.style.display = 'flex';
1290
+ hideLatestBar();
1291
  showAuth();
1292
  updateOnlineBadge();
1293
  });
1294
 
 
1295
  if (window.Notification && Notification.permission === 'default') {
1296
  Notification.requestPermission();
1297
  }
1298
 
 
1299
  window.InfinityChat = { replyTo, editMessage, deleteMessage };
1300
 
1301
  init();