Spaces:
Running
Running
Update static/script.js
Browse files- 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
|
| 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
|
| 114 |
-
const
|
| 115 |
-
const
|
| 116 |
-
document.documentElement.style.setProperty('--bubble-self',
|
| 117 |
-
document.documentElement.style.setProperty('--bubble-other',
|
| 118 |
-
document.documentElement.style.setProperty('--bubble-other-text',
|
| 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(
|
| 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 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
// =============================================
|
| 137 |
function startTimestampUpdater() {
|
| 138 |
setInterval(() => {
|
|
@@ -143,19 +196,19 @@
|
|
| 143 |
}
|
| 144 |
|
| 145 |
// =============================================
|
| 146 |
-
// Toast
|
| 147 |
// =============================================
|
| 148 |
function showToast(message, type = 'info') {
|
| 149 |
const toast = document.createElement('div');
|
| 150 |
toast.className = `toast toast-${type}`;
|
| 151 |
-
const icons = { success:
|
| 152 |
-
toast.innerHTML = `<i class="fas fa-${icons[type]
|
| 153 |
DOM.toastContainer.appendChild(toast);
|
| 154 |
setTimeout(() => toast.remove(), 4000);
|
| 155 |
}
|
| 156 |
|
| 157 |
// =============================================
|
| 158 |
-
//
|
| 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 |
-
|
| 174 |
-
|
| 175 |
-
|
|
|
|
| 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 |
-
|
| 245 |
-
State.ws = new WebSocket(url);
|
| 246 |
-
|
| 247 |
State.ws.onopen = () => {
|
| 248 |
State.wsState = 'connected';
|
| 249 |
updateConnectionUI();
|
| 250 |
loadMessages();
|
| 251 |
requestOnlineUsers();
|
| 252 |
};
|
| 253 |
-
|
| 254 |
-
|
| 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 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 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 |
-
|
| 317 |
-
|
| 318 |
-
existing.status = m.status;
|
| 319 |
updateMessageBubble(m.id);
|
| 320 |
return;
|
| 321 |
}
|
| 322 |
State.messages.set(m.id, m);
|
| 323 |
-
|
| 324 |
-
insertMessageIdSorted(m.id, m.timestamp_ms);
|
| 325 |
renderMessage(m, true);
|
| 326 |
-
|
| 327 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 369 |
-
} else {
|
| 370 |
-
State.typingUsers.delete(msg.user_id);
|
| 371 |
-
}
|
| 372 |
updateTypingIndicator();
|
| 373 |
break;
|
| 374 |
|
|
@@ -411,16 +453,86 @@
|
|
| 411 |
}
|
| 412 |
}
|
| 413 |
|
| 414 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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
|
| 501 |
if (!isSelf) {
|
| 502 |
-
html += `<div class="msg-sender-row">
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 512 |
html += `<div class="reply-indicator">
|
| 513 |
<i class="fas fa-reply"></i>
|
| 514 |
-
<span><strong>${
|
| 515 |
</div>`;
|
| 516 |
}
|
| 517 |
|
| 518 |
html += `<div class="message-bubble">`;
|
| 519 |
|
| 520 |
-
//
|
| 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
|
| 531 |
-
html += `<div class="message-meta">
|
| 532 |
-
|
| 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 |
-
|
| 538 |
-
|
| 539 |
}
|
| 540 |
html += `</div>`;
|
| 541 |
|
| 542 |
-
//
|
| 543 |
-
html += `<div class="message-actions">
|
| 544 |
-
|
| 545 |
if (isSelf) {
|
| 546 |
-
html += `<button onclick="InfinityChat.editMessage(${msg.id})" title="Edit"><i class="fas fa-pen"></i></button>
|
| 547 |
-
|
| 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.
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
</div>
|
| 582 |
-
</div>
|
| 583 |
</div>`;
|
| 584 |
}
|
| 585 |
-
|
| 586 |
if (ft.startsWith('video/')) {
|
| 587 |
return `<div class="attachment">
|
| 588 |
-
<video controls preload="metadata"
|
| 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 |
-
|
| 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(', ') +
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 777 |
-
State.ws.send(JSON.stringify({ type: 'delete_message', message_id: messageId }));
|
| 778 |
-
}
|
| 779 |
}
|
| 780 |
}
|
| 781 |
|
| 782 |
// =============================================
|
| 783 |
-
// File Upload
|
| 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
|
| 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 |
-
|
| 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
|
| 914 |
// =============================================
|
| 915 |
DOM.loadOlderBtn.addEventListener('click', () => {
|
| 916 |
if (State.ws?.readyState === WebSocket.OPEN && State.hasMore && State.cursor) {
|
| 917 |
-
State.isAtBottom = false;
|
| 918 |
State.ws.send(JSON.stringify({ type: 'load_messages', cursor: State.cursor, limit: 50 }));
|
| 919 |
}
|
| 920 |
});
|
| 921 |
|
| 922 |
// =============================================
|
| 923 |
-
// Online Users
|
| 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 |
-
|
| 953 |
-
avatarHtml = initials;
|
| 954 |
}
|
| 955 |
div.innerHTML = `
|
| 956 |
<div class="user-avatar">${avatarHtml}</div>
|
|
@@ -964,7 +1021,7 @@
|
|
| 964 |
}
|
| 965 |
|
| 966 |
// =============================================
|
| 967 |
-
// Settings
|
| 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 |
-
//
|
| 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
|
| 1195 |
-
if (img) {
|
| 1196 |
-
|
| 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
|
| 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();
|