smodusermc commited on
Commit
a9d0c4b
·
verified ·
1 Parent(s): e6051e5

Update static/script.js

Browse files
Files changed (1) hide show
  1. static/script.js +679 -192
static/script.js CHANGED
@@ -1,6 +1,10 @@
 
1
  (function() {
2
  'use strict';
3
 
 
 
 
4
  const State = {
5
  token: localStorage.getItem('token'),
6
  user: null,
@@ -23,135 +27,200 @@
23
  }
24
  };
25
 
 
 
 
26
  const DOM = {
 
27
  authScreen: document.getElementById('auth-screen'),
28
  chatScreen: document.getElementById('chat-screen'),
 
 
29
  loginForm: document.getElementById('login-form'),
30
  signupForm: document.getElementById('signup-form'),
31
  authTabs: document.querySelectorAll('.auth-tab'),
32
  authError: document.getElementById('auth-error'),
33
  authErrorText: document.getElementById('auth-error-text'),
34
  authLoading: document.getElementById('auth-loading'),
 
 
35
  headerDisplayName: document.getElementById('header-display-name'),
36
  headerAvatar: document.getElementById('header-avatar'),
37
  headerAvatarPlaceholder: document.getElementById('header-avatar-placeholder'),
38
  headerStatus: document.getElementById('header-status'),
39
  onlineBadge: document.getElementById('online-badge'),
 
 
40
  connectionStatus: document.getElementById('connection-status'),
41
  connectionText: document.getElementById('connection-text'),
 
 
42
  messagesList: document.getElementById('messages-list'),
43
  messagesScroll: document.getElementById('messages-scroll'),
44
  emptyState: document.getElementById('empty-state'),
45
  loadOlderBtn: document.getElementById('load-older-btn'),
46
  scrollBottomBtn: document.getElementById('scroll-bottom-btn'),
 
 
47
  typingIndicator: document.getElementById('typing-indicator'),
48
  typingText: document.getElementById('typing-text'),
 
 
49
  replyPreview: document.getElementById('reply-preview'),
50
  replyUser: document.getElementById('reply-user'),
51
  replyText: document.getElementById('reply-text'),
 
 
52
  composeForm: document.getElementById('compose-form'),
53
  messageInput: document.getElementById('message-input'),
54
  sendBtn: document.getElementById('send-btn'),
55
  attachBtn: document.getElementById('attach-btn'),
56
  emojiBtn: document.getElementById('emoji-btn'),
57
  fileInput: document.getElementById('file-input'),
 
 
58
  uploadPreview: document.getElementById('upload-preview'),
59
  uploadFilename: document.getElementById('upload-filename'),
60
  cancelUpload: document.getElementById('cancel-upload'),
61
  uploadProgress: document.getElementById('upload-progress'),
62
  progressFill: document.getElementById('progress-fill'),
63
  progressText: document.getElementById('progress-text'),
 
 
64
  settingsModal: document.getElementById('settings-modal'),
65
  onlineModal: document.getElementById('online-modal'),
66
  emojiPicker: document.getElementById('emoji-picker'),
67
  emojiGrid: document.getElementById('emoji-grid'),
 
 
68
  settingsDisplayName: document.getElementById('settings-display-name'),
69
  settingsUsername: document.getElementById('settings-username'),
70
  settingsAvatarPreview: document.getElementById('settings-avatar-preview'),
71
  settingsAvatarImg: document.getElementById('settings-avatar-img'),
72
  avatarInput: document.getElementById('avatar-input'),
 
73
  themeOptions: document.querySelectorAll('.theme-option'),
74
  customThemeSettings: document.getElementById('custom-theme-settings'),
 
 
 
 
 
 
 
 
75
  onlineBtn: document.getElementById('online-btn'),
76
  onlineUsersList: document.getElementById('online-users-list'),
77
  onlineTotal: document.getElementById('online-total'),
 
 
78
  toastContainer: document.getElementById('toast-container')
79
  };
80
 
81
  const API = window.location.origin;
82
 
 
 
 
83
  function applyTheme(theme) {
84
  document.documentElement.setAttribute('data-theme', theme);
85
  State.theme = theme;
86
  localStorage.setItem('theme', theme);
87
  if (theme === 'custom') {
88
- document.documentElement.style.setProperty('--bubble-self', State.customColors.self);
89
- document.documentElement.style.setProperty('--bubble-other', State.customColors.other);
90
- document.documentElement.style.setProperty('--bubble-other-text', State.customColors.text);
91
- document.documentElement.style.setProperty('--bubble-self-text', isLightColor(State.customColors.self) ? '#111827' : '#FFFFFF');
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  }
93
  }
94
 
95
- function isLightColor(hex) {
96
- const r = parseInt(hex.slice(1,3), 16), g = parseInt(hex.slice(3,5), 16), b = parseInt(hex.slice(5,7), 16);
97
- return (0.299 * r + 0.587 * g + 0.114 * b) > 150;
98
- }
99
-
100
  applyTheme(State.theme);
101
 
102
- function showToast(msg, type = 'info') {
 
 
 
103
  const toast = document.createElement('div');
104
  toast.className = `toast toast-${type}`;
105
- toast.innerHTML = `<i class="fas fa-${type === 'success' ? 'check-circle' : type === 'error' ? 'exclamation-circle' : 'info-circle'}"></i><span>${msg}</span>`;
 
 
 
 
106
  DOM.toastContainer.appendChild(toast);
107
  setTimeout(() => toast.remove(), 4000);
108
  }
109
 
110
- // Auth
111
- DOM.authTabs.forEach(tab => tab.addEventListener('click', () => {
112
- const target = tab.dataset.tab;
113
- DOM.authTabs.forEach(t => t.classList.remove('active'));
114
- tab.classList.add('active');
115
- DOM.loginForm.classList.toggle('active', target === 'login');
116
- DOM.signupForm.classList.toggle('active', target === 'signup');
117
- DOM.authError.style.display = 'none';
118
- }));
 
 
 
 
119
 
120
- DOM.loginForm.addEventListener('submit', async e => {
121
  e.preventDefault();
122
- const u = document.getElementById('login-username').value.trim();
123
- const p = document.getElementById('login-password').value;
124
- await login(u, p);
125
  });
126
 
127
- DOM.signupForm.addEventListener('submit', async e => {
128
  e.preventDefault();
129
- const u = document.getElementById('signup-username').value.trim();
130
- const d = document.getElementById('signup-display').value.trim() || u;
131
- const p = document.getElementById('signup-password').value;
132
- await signup(u, p, d);
133
  });
134
 
135
  async function login(username, password) {
136
  DOM.authLoading.classList.remove('hidden');
 
137
  try {
138
  const res = await fetch(`${API}/api/auth/login?username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`, { method: 'POST' });
139
  const data = await res.json();
140
  if (!res.ok) throw new Error(data.detail || 'Login failed');
141
  handleAuthSuccess(data);
142
- } catch (err) { showAuthError(err.message); }
143
- finally { DOM.authLoading.classList.add('hidden'); }
 
 
 
144
  }
145
 
146
  async function signup(username, password, display) {
147
  DOM.authLoading.classList.remove('hidden');
 
148
  try {
149
  const res = await fetch(`${API}/api/auth/signup?username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}&display_name=${encodeURIComponent(display)}`, { method: 'POST' });
150
  const data = await res.json();
151
  if (!res.ok) throw new Error(data.detail || 'Signup failed');
152
  handleAuthSuccess(data);
153
- } catch (err) { showAuthError(err.message); }
154
- finally { DOM.authLoading.classList.add('hidden'); }
 
 
 
155
  }
156
 
157
  function handleAuthSuccess(data) {
@@ -167,135 +236,218 @@
167
  DOM.authError.style.display = 'flex';
168
  }
169
 
170
- // WS
 
 
 
 
 
 
171
  function connectWebSocket() {
172
- if (!State.token || (State.ws && State.ws.readyState === WebSocket.OPEN)) return;
 
 
173
  State.wsState = 'connecting';
174
  updateConnectionUI();
 
175
  const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
176
- State.ws = new WebSocket(`${proto}//${location.host}/ws?token=${encodeURIComponent(State.token)}`);
 
 
177
  State.ws.onopen = () => {
178
  State.wsState = 'connected';
179
  updateConnectionUI();
 
 
180
  };
181
- State.ws.onmessage = e => handleWSMessage(JSON.parse(e.data));
 
 
 
 
 
 
 
 
 
182
  State.ws.onclose = () => {
183
- if (State.wsState === 'connected') { State.wsState = 'reconnecting'; setTimeout(connectWebSocket, 3000); }
184
- else State.wsState = 'disconnected';
 
 
 
 
 
 
 
 
 
 
185
  updateConnectionUI();
186
  };
187
- State.ws.onerror = () => { State.wsState = 'disconnected'; updateConnectionUI(); };
188
  }
189
 
190
  function updateConnectionUI() {
191
- const s = DOM.connectionStatus;
192
- s.className = 'connection-status';
193
- if (State.wsState === 'connected') { s.classList.add('connected'); DOM.connectionText.textContent = 'Connected'; }
194
- else if (State.wsState === 'reconnecting') { s.classList.add('reconnecting'); DOM.connectionText.textContent = 'Reconnecting...'; }
195
- else { s.classList.add('disconnected'); DOM.connectionText.textContent = 'Disconnected'; }
 
 
 
 
 
 
 
 
 
 
196
  }
197
 
198
  function handleWSMessage(msg) {
199
  switch (msg.type) {
200
  case 'connection_established':
201
- State.user = { id: msg.user_id, username: msg.username, display_name: msg.display_name, avatar_path: msg.avatar_path };
 
 
 
 
 
202
  msg.online_users.forEach(u => State.onlineUsers.set(u.id, u));
203
- updateOnlineBadge();
204
  updateHeader();
205
- loadMessages();
206
  break;
 
207
  case 'new_message':
208
- handleNewMessage(msg.message);
 
 
 
 
 
 
 
 
 
 
 
209
  break;
 
210
  case 'messages_loaded':
211
- msg.messages.forEach(m => { if (!State.messages.has(m.id)) { State.messages.set(m.id, m); State.messageOrder.unshift(m.id); } });
 
 
 
 
 
212
  State.hasMore = msg.has_more;
213
- State.cursor = msg.next_cursor;
214
  renderAllMessages();
215
  DOM.loadOlderBtn.classList.toggle('hidden', !msg.has_more);
216
  break;
 
217
  case 'user_status':
218
- if (msg.status === 'online') State.onlineUsers.set(msg.user_id, msg);
219
- else State.onlineUsers.delete(msg.user_id);
 
 
 
 
 
 
 
 
220
  updateOnlineBadge();
221
  break;
 
222
  case 'typing_indicator':
223
- if (msg.is_typing) State.typingUsers.set(msg.user_id, msg);
224
- else State.typingUsers.delete(msg.user_id);
 
 
 
225
  updateTypingIndicator();
226
  break;
 
227
  case 'message_edited':
228
  if (State.messages.has(msg.message_id)) {
229
- State.messages.get(msg.message_id).content = msg.content;
230
- State.messages.get(msg.message_id).is_edited = true;
 
231
  updateMessageBubble(msg.message_id);
232
  }
233
  break;
 
234
  case 'message_deleted':
235
  if (State.messages.has(msg.message_id)) {
236
- State.messages.get(msg.message_id).is_deleted = true;
 
237
  updateMessageBubble(msg.message_id);
238
  }
239
  break;
 
240
  case 'message_read':
241
  if (State.messages.has(msg.message_id) && State.messages.get(msg.message_id).sender_id === State.user.id) {
242
  State.messages.get(msg.message_id).status = 'read';
243
  updateMessageBubble(msg.message_id);
244
  }
245
  break;
 
246
  case 'online_users':
247
  State.onlineUsers.clear();
248
  msg.users.forEach(u => State.onlineUsers.set(u.id, u));
249
  updateOnlineBadge();
 
250
  break;
 
251
  case 'error':
252
  showToast(msg.message, 'error');
253
  }
254
  }
255
 
256
- function handleNewMessage(msg) {
257
- if (State.messages.has(msg.id)) return;
258
- State.messages.set(msg.id, msg);
259
- State.messageOrder.push(msg.id);
260
- renderMessage(msg);
261
- if (State.isAtBottom) scrollToBottom();
262
- if (document.visibilityState === 'visible') sendMarkRead(msg.id);
263
- if (document.visibilityState !== 'visible' && Notification.permission === 'granted') {
264
- new Notification(`${msg.sender_display_name}`, { body: msg.content || 'Sent a file' });
265
- }
266
- }
267
-
268
  function loadMessages() {
269
- if (State.ws?.readyState === WebSocket.OPEN) State.ws.send(JSON.stringify({ type: 'load_messages', limit: 50 }));
 
 
270
  }
271
 
272
  function sendMarkRead(messageId) {
273
- if (State.ws?.readyState === WebSocket.OPEN) State.ws.send(JSON.stringify({ type: 'mark_read', up_to_message_id: messageId }));
 
 
274
  }
275
 
276
- // Render
 
 
277
  function renderMessage(msg) {
278
  if (DOM.messagesList.querySelector(`[data-id="${msg.id}"]`)) return;
279
- const wrap = document.createElement('div');
280
- wrap.className = `message-wrapper ${msg.sender_id === State.user.id ? 'self' : 'other'}`;
281
- wrap.dataset.id = msg.id;
282
- wrap.innerHTML = buildMessageHTML(msg);
283
- DOM.messagesList.appendChild(wrap);
284
  }
285
 
286
  function buildMessageHTML(msg) {
287
  if (msg.is_deleted) return '<div class="message-bubble deleted">This message was deleted</div>';
288
  const isSelf = msg.sender_id === State.user?.id;
289
  let html = '';
 
290
  if (msg.reply_to_id && State.messages.has(msg.reply_to_id)) {
291
- const r = State.messages.get(msg.reply_to_id);
292
- html += `<div class="reply-indicator"><i class="fas fa-reply"></i><span>${r.sender_username}: ${truncate(r.content, 50)}</span></div>`;
293
  }
294
  html += `<div class="message-bubble">`;
295
  html += `<div class="message-text">${escapeHtml(msg.content)}</div>`;
296
- if (msg.file_path) html += buildAttachment(msg);
297
- html += `<div class="message-meta"><span class="message-time">${formatTime(msg.timestamp_ms)}</span>`;
298
- if (msg.is_edited) html += '<span class="edited-label">edited</span>';
 
299
  if (isSelf) {
300
  const icon = msg.status === 'read' ? '<i class="fas fa-check-double"></i>' : '<i class="fas fa-check"></i>';
301
  html += `<span class="message-status ${msg.status==='read'?'read':''}">${icon}</span>`;
@@ -310,12 +462,23 @@
310
  return html;
311
  }
312
 
313
- function buildAttachment(msg) {
314
  const url = `${API}/api/download/${encodeURIComponent(msg.file_path)}?token=${encodeURIComponent(State.token)}`;
315
- if (msg.file_type?.startsWith('image/')) return `<div class="attachment"><img src="${url}" alt="${msg.file_name}" loading="lazy"></div>`;
316
- if (msg.file_type?.startsWith('video/')) return `<div class="attachment"><video controls preload="metadata"><source src="${url}" type="${msg.file_type}"></video></div>`;
317
- if (msg.file_type?.startsWith('audio/')) return `<div class="attachment"><audio controls src="${url}"></audio></div>`;
318
- return `<div class="attachment-file" onclick="window.open('${url}')"><i class="fas fa-file"></i><div class="file-info"><div class="file-name">${msg.file_name||'File'}</div><div class="file-size">${formatFileSize(msg.file_size)}</div></div></div>`;
 
 
 
 
 
 
 
 
 
 
 
319
  }
320
 
321
  function updateMessageBubble(id) {
@@ -327,17 +490,25 @@
327
 
328
  function renderAllMessages() {
329
  DOM.messagesList.innerHTML = '';
330
- if (State.messageOrder.length === 0) DOM.emptyState.style.display = 'flex';
331
- else {
 
332
  DOM.emptyState.style.display = 'none';
333
- State.messageOrder.forEach(id => { if (State.messages.has(id)) renderMessage(State.messages.get(id)); });
 
 
 
334
  }
 
335
  }
336
 
337
  function scrollToBottom() {
338
  DOM.messagesScroll.scrollTop = DOM.messagesScroll.scrollHeight;
339
  }
340
 
 
 
 
341
  DOM.messagesScroll.addEventListener('scroll', () => {
342
  const { scrollTop, scrollHeight, clientHeight } = DOM.messagesScroll;
343
  State.isAtBottom = (scrollHeight - scrollTop - clientHeight) < 60;
@@ -345,29 +516,44 @@
345
  });
346
  DOM.scrollBottomBtn.addEventListener('click', scrollToBottom);
347
 
 
348
  // Compose
349
- DOM.composeForm.addEventListener('submit', e => { e.preventDefault(); sendMessage(); });
 
 
 
 
 
350
  DOM.messageInput.addEventListener('input', () => {
351
- const has = DOM.messageInput.value.trim().length > 0 || State.pendingFile;
352
- DOM.sendBtn.disabled = !has;
353
- if (has) sendTyping(true);
354
  autoResize();
355
  });
356
 
357
  function sendMessage() {
358
  const content = DOM.messageInput.value.trim();
359
  if (!content && !State.pendingFile) return;
360
- if (State.ws?.readyState !== WebSocket.OPEN) return;
361
- State.ws.send(JSON.stringify({
 
 
 
 
362
  type: 'send_message',
363
- content,
364
- reply_to_id: State.replyTo?.id || null,
365
- client_id: generateId(),
366
- file_path: State.pendingFile?.file_path,
367
- file_type: State.pendingFile?.file_type,
368
- file_name: State.pendingFile?.file_name,
369
- file_size: State.pendingFile?.file_size
370
- }));
 
 
 
 
 
371
  DOM.messageInput.value = '';
372
  clearReply();
373
  clearPendingFile();
@@ -376,11 +562,11 @@
376
  }
377
 
378
  function sendTyping(isTyping) {
379
- if (State.ws?.readyState !== WebSocket.OPEN) return;
380
- clearTimeout(window._typingTimer);
381
  if (isTyping) {
382
  State.ws.send(JSON.stringify({ type: 'typing', is_typing: true }));
383
- window._typingTimer = setTimeout(() => sendTyping(false), 2000);
384
  } else {
385
  State.ws.send(JSON.stringify({ type: 'typing', is_typing: false }));
386
  }
@@ -389,40 +575,62 @@
389
  function updateTypingIndicator() {
390
  const users = Array.from(State.typingUsers.values()).filter(u => u.user_id !== State.user.id);
391
  DOM.typingIndicator.classList.toggle('hidden', users.length === 0);
392
- DOM.typingText.textContent = users.length ? users.map(u => u.display_name).join(', ') + ' typing...' : '';
 
 
393
  }
394
 
395
- function clearReply() { State.replyTo = null; DOM.replyPreview.classList.add('hidden'); }
396
- function clearPendingFile() { State.pendingFile = null; DOM.uploadPreview.classList.add('hidden'); }
 
 
 
 
 
 
 
 
 
 
397
 
398
- window.InfinityChat = {
399
- replyTo: (id) => {
400
- const msg = State.messages.get(id);
401
- if (!msg) return;
402
- State.replyTo = msg;
403
- DOM.replyUser.textContent = msg.sender_username;
404
- DOM.replyText.textContent = truncate(msg.content, 50);
405
- DOM.replyPreview.classList.remove('hidden');
406
- DOM.messageInput.focus();
407
- },
408
- editMessage: (id) => {
409
- const msg = State.messages.get(id);
410
- if (!msg) return;
411
- const newContent = prompt('Edit:', msg.content);
412
- if (newContent !== null && newContent.trim() !== '') {
413
- State.ws.send(JSON.stringify({ type: 'edit_message', message_id: id, content: newContent }));
 
 
 
 
 
414
  }
415
- },
416
- deleteMessage: (id) => {
417
- if (confirm('Delete permanently?')) {
418
- State.ws.send(JSON.stringify({ type: 'delete_message', message_id: id }));
 
 
 
419
  }
420
  }
421
- };
422
 
423
- // File upload
 
 
424
  DOM.attachBtn.addEventListener('click', () => DOM.fileInput.click());
425
- DOM.fileInput.addEventListener('change', async e => {
426
  const file = e.target.files[0];
427
  if (!file) return;
428
  await uploadChunked(file);
@@ -430,41 +638,141 @@
430
  });
431
 
432
  async function uploadChunked(file) {
433
- const CHUNK = 5*1024*1024, total = Math.ceil(file.size/CHUNK), uploadId = generateId();
 
 
 
434
  DOM.uploadProgress.classList.remove('hidden');
435
- for (let i=0; i<total; i++) {
436
- const start = i*CHUNK, end = Math.min(start+CHUNK, file.size);
437
- const blob = file.slice(start,end);
438
- const fd = new FormData(); fd.append('file', blob);
439
- const res = await fetch(`${API}/api/upload/chunk?chunk_index=${i}&total_chunks=${total}&file_name=${encodeURIComponent(file.name)}&file_type=${encodeURIComponent(file.type||'application/octet-stream')}&file_size=${file.size}&upload_id=${uploadId}`, { method:'POST', body:fd, headers:{'X-Auth-Token':State.token} });
440
- if (!res.ok) { alert('Upload failed'); break; }
441
- const data = await res.json();
442
- updateProgress(((i+1)/total)*100);
443
- if (data.status === 'complete') {
444
- State.pendingFile = { file_path: data.file_path, file_type: data.file_type, file_name: data.file_name, file_size: file_size };
445
- DOM.uploadPreview.classList.remove('hidden');
446
- DOM.uploadFilename.textContent = data.file_name;
447
- DOM.sendBtn.disabled = false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
448
  DOM.uploadProgress.classList.add('hidden');
449
- return;
450
  }
451
  }
452
  DOM.uploadProgress.classList.add('hidden');
453
  }
454
 
455
- function updateProgress(pct) {
456
- DOM.progressFill.style.width = `${pct}%`;
457
- DOM.progressText.textContent = `${Math.round(pct)}%`;
 
 
 
 
 
 
 
 
 
 
 
 
458
  }
459
 
460
  DOM.cancelUpload.addEventListener('click', clearPendingFile);
461
 
462
- // Online modal
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
463
  DOM.onlineBtn.addEventListener('click', () => {
464
  renderOnlineUsers();
465
  DOM.onlineModal.classList.remove('hidden');
466
  });
467
- document.querySelectorAll('[data-close="online-modal"]').forEach(b => b.addEventListener('click', () => DOM.onlineModal.classList.add('hidden')));
 
 
 
468
 
469
  function updateOnlineBadge() {
470
  DOM.onlineBadge.textContent = State.onlineUsers.size;
@@ -480,14 +788,26 @@
480
  State.onlineUsers.forEach(u => {
481
  const div = document.createElement('div');
482
  div.className = 'user-item';
483
- div.innerHTML = `<div class="user-avatar"><i class="fas fa-user-circle"></i></div><div class="user-info"><div class="user-name">${u.display_name || u.username}</div><div class="user-status-text">online</div></div>`;
 
 
 
 
484
  DOM.onlineUsersList.appendChild(div);
485
  });
486
  DOM.onlineTotal.textContent = `${State.onlineUsers.size} users online`;
487
  }
488
 
489
- // Settings
490
- document.getElementById('settings-btn').addEventListener('click', () => {
 
 
 
 
 
 
 
 
491
  DOM.settingsDisplayName.value = State.user?.display_name || '';
492
  DOM.settingsUsername.value = State.user?.username || '';
493
  if (State.user?.avatar_path) {
@@ -498,70 +818,237 @@
498
  DOM.settingsAvatarImg.classList.add('hidden');
499
  DOM.settingsAvatarPreview.classList.remove('hidden');
500
  }
501
- DOM.themeOptions.forEach(o => o.classList.toggle('active', o.dataset.theme === State.theme));
 
 
 
 
502
  DOM.customThemeSettings.classList.toggle('hidden', State.theme !== 'custom');
 
 
 
 
 
 
 
 
 
503
  DOM.settingsModal.classList.remove('hidden');
504
- });
505
- document.querySelectorAll('[data-close="settings-modal"]').forEach(b => b.addEventListener('click', () => DOM.settingsModal.classList.add('hidden')));
506
- document.getElementById('save-settings').addEventListener('click', async () => {
507
  const newName = DOM.settingsDisplayName.value.trim();
508
  if (newName && newName !== State.user?.display_name) {
509
- await fetch(`${API}/api/profile?display_name=${encodeURIComponent(newName)}`, { method:'PATCH', headers:{'X-Auth-Token':State.token} });
 
 
 
510
  State.user.display_name = newName;
511
  updateHeader();
512
  showToast('Profile updated', 'success');
513
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
514
  DOM.settingsModal.classList.add('hidden');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
515
  });
516
 
517
- DOM.themeOptions.forEach(opt => opt.addEventListener('click', () => {
518
- applyTheme(opt.dataset.theme);
519
- DOM.themeOptions.forEach(o => o.classList.remove('active'));
520
- opt.classList.add('active');
521
- DOM.customThemeSettings.classList.toggle('hidden', opt.dataset.theme !== 'custom');
522
- }));
 
 
 
 
 
 
 
 
523
 
524
- function updateHeader() {
525
- if (State.user) DOM.headerDisplayName.textContent = State.user.display_name;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
526
  }
527
 
528
- // Utility
529
- function generateId() { return Date.now().toString(36) + Math.random().toString(36).substr(2,9); }
530
- function truncate(s,n) { return s && s.length > n ? s.substr(0,n-1)+'…' : s || ''; }
531
- function escapeHtml(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
532
  function formatTime(ms) {
533
- const d = new Date(ms), n = new Date();
534
- if (d.toDateString() === n.toDateString()) return d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
535
- return d.toLocaleDateString([], {month:'short',day:'numeric'}) + ' ' + d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
 
 
 
 
 
 
536
  }
537
- function formatFileSize(b) {
538
- if (!b) return '0 B';
539
- const k=1024, sizes=['B','KB','MB','GB'], i=Math.floor(Math.log(b)/Math.log(k));
540
- return parseFloat((b/Math.pow(k,i)).toFixed(1))+' '+sizes[i];
 
 
 
541
  }
 
542
  function autoResize() {
543
  DOM.messageInput.style.height = 'auto';
544
  DOM.messageInput.style.height = Math.min(DOM.messageInput.scrollHeight, 120) + 'px';
545
  }
546
 
547
- // Init
 
 
548
  async function init() {
549
  if (State.token) {
550
- const res = await fetch(`${API}/api/auth/verify`, { headers: {'X-Auth-Token': State.token} });
551
- if (res.ok) {
552
- const data = await res.json();
553
- State.user = data.user;
554
- showChat();
555
- connectWebSocket();
556
- return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
557
  }
 
 
 
 
 
 
 
 
 
 
558
  }
 
 
 
 
 
 
 
 
 
 
559
  showAuth();
 
 
 
 
 
 
560
  }
561
 
562
- function showAuth() { DOM.authScreen.classList.add('active'); DOM.chatScreen.classList.remove('active'); }
563
- function showChat() { DOM.authScreen.classList.remove('active'); DOM.chatScreen.classList.add('active'); updateHeader(); }
 
 
 
 
564
 
565
- if (window.Notification && Notification.permission === 'default') Notification.requestPermission();
566
  init();
567
  })();
 
1
+ // static/script.js
2
  (function() {
3
  'use strict';
4
 
5
+ // =============================================
6
+ // State
7
+ // =============================================
8
  const State = {
9
  token: localStorage.getItem('token'),
10
  user: null,
 
27
  }
28
  };
29
 
30
+ // =============================================
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'),
99
  settingsAvatarImg: document.getElementById('settings-avatar-img'),
100
  avatarInput: document.getElementById('avatar-input'),
101
+ avatarUploadBtn: document.getElementById('avatar-upload-btn'),
102
  themeOptions: document.querySelectorAll('.theme-option'),
103
  customThemeSettings: document.getElementById('custom-theme-settings'),
104
+ customSelfColor: document.getElementById('custom-self-color'),
105
+ customOtherColor: document.getElementById('custom-other-color'),
106
+ customTextColor: document.getElementById('custom-text-color'),
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
+ // =============================================
125
  function applyTheme(theme) {
126
  document.documentElement.setAttribute('data-theme', theme);
127
  State.theme = theme;
128
  localStorage.setItem('theme', theme);
129
  if (theme === 'custom') {
130
+ const selfColor = State.customColors.self;
131
+ const otherColor = State.customColors.other;
132
+ const textColor = State.customColors.text;
133
+ document.documentElement.style.setProperty('--bubble-self', selfColor);
134
+ document.documentElement.style.setProperty('--bubble-other', otherColor);
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');
146
+ document.documentElement.style.removeProperty('--bubble-self-text');
147
  }
148
  }
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
  }
166
 
167
+ // =============================================
168
+ // Authentication
169
+ // =============================================
170
+ DOM.authTabs.forEach(tab => {
171
+ tab.addEventListener('click', () => {
172
+ const target = tab.dataset.tab;
173
+ DOM.authTabs.forEach(t => t.classList.remove('active'));
174
+ tab.classList.add('active');
175
+ DOM.loginForm.classList.toggle('active', target === 'login');
176
+ DOM.signupForm.classList.toggle('active', target === 'signup');
177
+ DOM.authError.style.display = 'none';
178
+ });
179
+ });
180
 
181
+ DOM.loginForm.addEventListener('submit', async (e) => {
182
  e.preventDefault();
183
+ const username = document.getElementById('login-username').value.trim();
184
+ const password = document.getElementById('login-password').value;
185
+ await login(username, password);
186
  });
187
 
188
+ DOM.signupForm.addEventListener('submit', async (e) => {
189
  e.preventDefault();
190
+ const username = document.getElementById('signup-username').value.trim();
191
+ const display = document.getElementById('signup-display').value.trim() || username;
192
+ const password = document.getElementById('signup-password').value;
193
+ await signup(username, password, display);
194
  });
195
 
196
  async function login(username, password) {
197
  DOM.authLoading.classList.remove('hidden');
198
+ hideAuthError();
199
  try {
200
  const res = await fetch(`${API}/api/auth/login?username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`, { method: 'POST' });
201
  const data = await res.json();
202
  if (!res.ok) throw new Error(data.detail || 'Login failed');
203
  handleAuthSuccess(data);
204
+ } catch (err) {
205
+ showAuthError(err.message);
206
+ } finally {
207
+ DOM.authLoading.classList.add('hidden');
208
+ }
209
  }
210
 
211
  async function signup(username, password, display) {
212
  DOM.authLoading.classList.remove('hidden');
213
+ hideAuthError();
214
  try {
215
  const res = await fetch(`${API}/api/auth/signup?username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}&display_name=${encodeURIComponent(display)}`, { method: 'POST' });
216
  const data = await res.json();
217
  if (!res.ok) throw new Error(data.detail || 'Signup failed');
218
  handleAuthSuccess(data);
219
+ } catch (err) {
220
+ showAuthError(err.message);
221
+ } finally {
222
+ DOM.authLoading.classList.add('hidden');
223
+ }
224
  }
225
 
226
  function handleAuthSuccess(data) {
 
236
  DOM.authError.style.display = 'flex';
237
  }
238
 
239
+ function hideAuthError() {
240
+ DOM.authError.style.display = 'none';
241
+ }
242
+
243
+ // =============================================
244
+ // WebSocket
245
+ // =============================================
246
  function connectWebSocket() {
247
+ if (!State.token) return;
248
+ if (State.ws && State.ws.readyState === WebSocket.OPEN) return;
249
+
250
  State.wsState = 'connecting';
251
  updateConnectionUI();
252
+
253
  const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
254
+ const url = `${proto}//${location.host}/ws?token=${encodeURIComponent(State.token)}`;
255
+ State.ws = new WebSocket(url);
256
+
257
  State.ws.onopen = () => {
258
  State.wsState = 'connected';
259
  updateConnectionUI();
260
+ loadMessages();
261
+ requestOnlineUsers();
262
  };
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
+ }
271
+ };
272
+
273
  State.ws.onclose = () => {
274
+ if (State.wsState === 'connected') {
275
+ State.wsState = 'reconnecting';
276
+ updateConnectionUI();
277
+ setTimeout(connectWebSocket, 3000);
278
+ } else {
279
+ State.wsState = 'disconnected';
280
+ updateConnectionUI();
281
+ }
282
+ };
283
+
284
+ State.ws.onerror = () => {
285
+ State.wsState = 'disconnected';
286
  updateConnectionUI();
287
  };
 
288
  }
289
 
290
  function updateConnectionUI() {
291
+ const status = DOM.connectionStatus;
292
+ status.className = 'connection-status';
293
+ switch (State.wsState) {
294
+ case 'connected':
295
+ status.classList.add('connected');
296
+ DOM.connectionText.textContent = 'Connected';
297
+ break;
298
+ case 'reconnecting':
299
+ status.classList.add('reconnecting');
300
+ DOM.connectionText.textContent = 'Reconnecting...';
301
+ break;
302
+ default:
303
+ status.classList.add('disconnected');
304
+ DOM.connectionText.textContent = 'Disconnected';
305
+ }
306
  }
307
 
308
  function handleWSMessage(msg) {
309
  switch (msg.type) {
310
  case 'connection_established':
311
+ State.user = {
312
+ id: msg.user_id,
313
+ username: msg.username,
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;
345
+ if (msg.next_cursor) State.cursor = msg.next_cursor;
346
  renderAllMessages();
347
  DOM.loadOlderBtn.classList.toggle('hidden', !msg.has_more);
348
  break;
349
+
350
  case 'user_status':
351
+ if (msg.status === 'online') {
352
+ State.onlineUsers.set(msg.user_id, {
353
+ id: msg.user_id,
354
+ username: msg.username,
355
+ display_name: msg.display_name,
356
+ avatar_path: msg.avatar_path
357
+ });
358
+ } else {
359
+ State.onlineUsers.delete(msg.user_id);
360
+ }
361
  updateOnlineBadge();
362
  break;
363
+
364
  case 'typing_indicator':
365
+ if (msg.is_typing) {
366
+ State.typingUsers.set(msg.user_id, msg);
367
+ } else {
368
+ State.typingUsers.delete(msg.user_id);
369
+ }
370
  updateTypingIndicator();
371
  break;
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
+
397
  case 'online_users':
398
  State.onlineUsers.clear();
399
  msg.users.forEach(u => State.onlineUsers.set(u.id, u));
400
  updateOnlineBadge();
401
+ if (!DOM.onlineModal.classList.contains('hidden')) renderOnlineUsers();
402
  break;
403
+
404
  case 'error':
405
  showToast(msg.message, 'error');
406
  }
407
  }
408
 
409
+ // =============================================
410
+ // Message Actions
411
+ // =============================================
 
 
 
 
 
 
 
 
 
412
  function loadMessages() {
413
+ if (State.ws?.readyState === WebSocket.OPEN) {
414
+ State.ws.send(JSON.stringify({ type: 'load_messages', limit: 50 }));
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 }));
421
+ }
422
  }
423
 
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>`;
 
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) {
 
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() {
506
  DOM.messagesScroll.scrollTop = DOM.messagesScroll.scrollHeight;
507
  }
508
 
509
+ // =============================================
510
+ // Scroll detection
511
+ // =============================================
512
  DOM.messagesScroll.addEventListener('scroll', () => {
513
  const { scrollTop, scrollHeight, clientHeight } = DOM.messagesScroll;
514
  State.isAtBottom = (scrollHeight - scrollTop - clientHeight) < 60;
 
516
  });
517
  DOM.scrollBottomBtn.addEventListener('click', scrollToBottom);
518
 
519
+ // =============================================
520
  // Compose
521
+ // =============================================
522
+ DOM.composeForm.addEventListener('submit', (e) => {
523
+ e.preventDefault();
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;
530
+ if (hasContent) sendTyping(true);
531
  autoResize();
532
  });
533
 
534
  function sendMessage() {
535
  const content = DOM.messageInput.value.trim();
536
  if (!content && !State.pendingFile) return;
537
+ if (!State.ws || State.ws.readyState !== WebSocket.OPEN) {
538
+ showToast('Not connected', 'warning');
539
+ return;
540
+ }
541
+
542
+ const payload = {
543
  type: 'send_message',
544
+ content: content,
545
+ reply_to_id: State.replyTo ? State.replyTo.id : null,
546
+ client_id: generateId()
547
+ };
548
+
549
+ if (State.pendingFile) {
550
+ payload.file_path = State.pendingFile.file_path;
551
+ payload.file_type = State.pendingFile.file_type;
552
+ payload.file_name = State.pendingFile.file_name;
553
+ payload.file_size = State.pendingFile.file_size;
554
+ }
555
+
556
+ State.ws.send(JSON.stringify(payload));
557
  DOM.messageInput.value = '';
558
  clearReply();
559
  clearPendingFile();
 
562
  }
563
 
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
  }
 
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
 
583
+ // =============================================
584
+ // Reply
585
+ // =============================================
586
+ function replyTo(messageId) {
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
+ }
595
 
596
+ function clearReply() {
597
+ State.replyTo = null;
598
+ DOM.replyPreview.classList.add('hidden');
599
+ }
600
+
601
+ document.addEventListener('click', (e) => {
602
+ if (e.target.id === 'cancel-reply' || e.target.closest('#cancel-reply')) {
603
+ clearReply();
604
+ }
605
+ });
606
+
607
+ // =============================================
608
+ // Edit / Delete
609
+ // =============================================
610
+ function editMessage(messageId) {
611
+ const msg = State.messages.get(messageId);
612
+ if (!msg) return;
613
+ const newContent = prompt('Edit message:', msg.content);
614
+ if (newContent !== null && newContent.trim() !== '') {
615
+ if (State.ws?.readyState === WebSocket.OPEN) {
616
+ State.ws.send(JSON.stringify({ type: 'edit_message', message_id: messageId, content: newContent.trim() }));
617
  }
618
+ }
619
+ }
620
+
621
+ function deleteMessage(messageId) {
622
+ if (confirm('Permanently delete this message?')) {
623
+ if (State.ws?.readyState === WebSocket.OPEN) {
624
+ State.ws.send(JSON.stringify({ type: 'delete_message', message_id: messageId }));
625
  }
626
  }
627
+ }
628
 
629
+ // =============================================
630
+ // File Upload (Chunked)
631
+ // =============================================
632
  DOM.attachBtn.addEventListener('click', () => DOM.fileInput.click());
633
+ DOM.fileInput.addEventListener('change', async (e) => {
634
  const file = e.target.files[0];
635
  if (!file) return;
636
  await uploadChunked(file);
 
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
+
645
  DOM.uploadProgress.classList.remove('hidden');
646
+ updateProgress(0, file.name);
647
+
648
+ for (let i = 0; i < totalChunks; i++) {
649
+ const start = i * CHUNK_SIZE;
650
+ const end = Math.min(start + CHUNK_SIZE, file.size);
651
+ const blob = file.slice(start, end);
652
+ const formData = new FormData();
653
+ formData.append('file', blob);
654
+
655
+ 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}`;
656
+
657
+ try {
658
+ const res = await fetch(url, {
659
+ method: 'POST',
660
+ body: formData,
661
+ headers: { 'X-Auth-Token': State.token }
662
+ });
663
+ if (!res.ok) throw new Error('Upload failed');
664
+ const data = await res.json();
665
+ updateProgress(((i + 1) / totalChunks) * 100, file.name);
666
+
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');
685
  }
686
 
687
+ function updateProgress(percent, filename) {
688
+ DOM.progressFill.style.width = `${percent}%`;
689
+ DOM.progressText.textContent = `${Math.round(percent)}%`;
690
+ if (filename) DOM.uploadFilename.textContent = filename;
691
+ }
692
+
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() {
700
+ State.pendingFile = null;
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();
711
+ DOM.emojiPicker.classList.toggle('hidden');
712
+ if (!DOM.emojiPicker.classList.contains('hidden')) {
713
+ renderEmojis('smileys');
714
+ }
715
+ });
716
+
717
+ document.addEventListener('click', (e) => {
718
+ if (!DOM.emojiPicker.contains(e.target) && e.target !== DOM.emojiBtn) {
719
+ DOM.emojiPicker.classList.add('hidden');
720
+ }
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 => {
735
+ btn.addEventListener('click', (e) => {
736
+ e.stopPropagation();
737
+ document.querySelectorAll('.emoji-cat').forEach(b => b.classList.remove('active'));
738
+ btn.classList.add('active');
739
+ renderEmojis(btn.dataset.cat);
740
+ });
741
+ });
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;
749
+ DOM.messageInput.focus();
750
+ DOM.messageInput.dispatchEvent(new Event('input'));
751
+ DOM.emojiPicker.classList.add('hidden');
752
+ });
753
+ });
754
+ }
755
+
756
+ // =============================================
757
+ // Load More Messages
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
+ });
764
+
765
+ // =============================================
766
+ // Online Users Modal
767
+ // =============================================
768
  DOM.onlineBtn.addEventListener('click', () => {
769
  renderOnlineUsers();
770
  DOM.onlineModal.classList.remove('hidden');
771
  });
772
+
773
+ document.querySelectorAll('[data-close="online-modal"]').forEach(btn => {
774
+ btn.addEventListener('click', () => DOM.onlineModal.classList.add('hidden'));
775
+ });
776
 
777
  function updateOnlineBadge() {
778
  DOM.onlineBadge.textContent = State.onlineUsers.size;
 
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
+ // =============================================
802
+ // Settings Modal
803
+ // =============================================
804
+ document.getElementById('settings-btn').addEventListener('click', openSettings);
805
+ document.querySelectorAll('[data-close="settings-modal"]').forEach(btn => {
806
+ btn.addEventListener('click', () => DOM.settingsModal.classList.add('hidden'));
807
+ });
808
+ document.getElementById('save-settings').addEventListener('click', saveSettings);
809
+
810
+ function openSettings() {
811
  DOM.settingsDisplayName.value = State.user?.display_name || '';
812
  DOM.settingsUsername.value = State.user?.username || '';
813
  if (State.user?.avatar_path) {
 
818
  DOM.settingsAvatarImg.classList.add('hidden');
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
+ });
826
  DOM.customThemeSettings.classList.toggle('hidden', State.theme !== 'custom');
827
+ if (State.theme === 'custom') {
828
+ DOM.customSelfColor.value = State.customColors.self;
829
+ DOM.customOtherColor.value = State.customColors.other;
830
+ DOM.customTextColor.value = State.customColors.text;
831
+ DOM.selfColorHex.textContent = State.customColors.self;
832
+ DOM.otherColorHex.textContent = State.customColors.other;
833
+ DOM.textColorHex.textContent = State.customColors.text;
834
+ }
835
+
836
  DOM.settingsModal.classList.remove('hidden');
837
+ }
838
+
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'));
897
+ opt.classList.add('active');
898
+ DOM.customThemeSettings.classList.toggle('hidden', opt.dataset.theme !== 'custom');
899
+ if (opt.dataset.theme === 'custom') {
900
+ DOM.customSelfColor.value = State.customColors.self;
901
+ DOM.customOtherColor.value = State.customColors.other;
902
+ DOM.customTextColor.value = State.customColors.text;
903
+ DOM.selfColorHex.textContent = State.customColors.self;
904
+ DOM.otherColorHex.textContent = State.customColors.other;
905
+ DOM.textColorHex.textContent = State.customColors.text;
906
+ }
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];
914
+ if (file) {
915
+ const reader = new FileReader();
916
+ reader.onload = (e) => {
917
+ DOM.settingsAvatarImg.src = e.target.result;
918
+ DOM.settingsAvatarImg.classList.remove('hidden');
919
+ DOM.settingsAvatarPreview.classList.add('hidden');
920
+ };
921
+ reader.readAsDataURL(file);
922
+ }
923
+ });
924
 
925
+ // =============================================
926
+ // Utility
927
+ // =============================================
928
+ function generateId() {
929
+ return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);
930
+ }
931
+
932
+ function truncate(str, n) {
933
+ if (!str) return '';
934
+ return str.length > n ? str.substr(0, n - 1) + '…' : str;
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
  }
954
+
955
+ function formatFileSize(bytes) {
956
+ if (!bytes) return '0 B';
957
+ const k = 1024;
958
+ const sizes = ['B', 'KB', 'MB', 'GB'];
959
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
960
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
961
  }
962
+
963
  function autoResize() {
964
  DOM.messageInput.style.height = 'auto';
965
  DOM.messageInput.style.height = Math.min(DOM.messageInput.scrollHeight, 120) + 'px';
966
  }
967
 
968
+ // =============================================
969
+ // Initialization
970
+ // =============================================
971
  async function init() {
972
  if (State.token) {
973
+ try {
974
+ const res = await fetch(`${API}/api/auth/verify`, {
975
+ headers: { 'X-Auth-Token': State.token }
976
+ });
977
+ if (res.ok) {
978
+ const data = await res.json();
979
+ State.user = data.user;
980
+ showChat();
981
+ connectWebSocket();
982
+ return;
983
+ }
984
+ } catch (e) {}
985
+ }
986
+ showAuth();
987
+ }
988
+
989
+ function showAuth() {
990
+ DOM.authScreen.classList.add('active');
991
+ DOM.chatScreen.classList.remove('active');
992
+ }
993
+
994
+ function showChat() {
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
+
1006
+ // Password visibility toggle
1007
+ document.querySelectorAll('.input-toggle').forEach(btn => {
1008
+ btn.addEventListener('click', () => {
1009
+ const targetId = btn.dataset.toggle;
1010
+ const input = document.getElementById(targetId);
1011
+ if (input) {
1012
+ input.type = input.type === 'password' ? 'text' : 'password';
1013
+ const icon = btn.querySelector('i');
1014
+ if (icon) icon.className = input.type === 'password' ? 'fas fa-eye' : 'fas fa-eye-slash';
1015
  }
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
  })();