/** * chat.js (frontend) — operator chat, embedded in the OPERATORZY panel. * * The online-users list is RadioLock's job (radiolock.js) - it already * knows who holds the TRX, which this file's older version didn't. This * file only renders the message thread. History arrives via the 'chat_init' * WS snapshot sent right after 'init' on every (re)connect (same pattern * as auto_seq_status/ft8_rx_status in ws.js) - no REST endpoint, session * history only (webapp.py's App._chat_history is an in-memory ring * buffer, cleared on server restart). * * Message types: * text → normal user message * system → system info (reserved for future use, e.g. joined/left) */ (function () { 'use strict'; const ROLE_COLORS = { admin: '#f0b429', operator: '#4cdb6a', viewer: 'rgba(200,220,200,0.5)', system: 'rgba(76,219,106,0.35)', }; // #m-chat-modal only exists on mobile.html - used below to tell the two // "shells" apart (mobile = full-screen modal + unread badge + no // drag-resize; desktop = always-visible panel with a CW-decoder-style // resize handle). The actual message rendering (appendMessage) is // identical either way. function _isMobile() { return !!document.getElementById('m-chat-modal'); } function _badgeId() { return _isMobile() ? 'm-chat-badge' : 'chat-badge'; } let _unreadCount = 0; // Desktop only - whether #chat-messages is actually scrolled into view // right now, tracked by IntersectionObserver below (mobile checks // #m-chat-modal's own display state directly instead, see handleWS). let _chatVisibleDesktop = false; function markRead() { _unreadCount = 0; const badge = document.getElementById(_badgeId()); if (badge) { badge.hidden = true; badge.textContent = '0'; } } // Desktop-only (2026-09-03, live report: the OPERATORZY+CZAT panel needs // scrolling to reach, so a topbar icon+badge was added to flag unread // messages without having to keep glancing down). Tracks whether the // panel is currently on-screen - also correctly goes false when the // operator switches to a different tab (switching tabs just toggles // .page-content's CSS display, which IntersectionObserver reports as // "not intersecting" too, not just page scroll position). function _initVisibilityTracking() { if (_isMobile()) return; const box = document.getElementById('chat-messages'); if (!box) return; try { const io = new IntersectionObserver(entries => { _chatVisibleDesktop = entries[0]?.isIntersecting || false; if (_chatVisibleDesktop) markRead(); }, { threshold: 0.3 }); io.observe(box); } catch (e) { /* IntersectionObserver unsupported - badge just won't auto-clear on scroll, scrollToChat() below still works */ } } // Called from the topbar chat icon (desktop only - mobile's icon calls // Mobile.openChat() directly instead). Scrolling the always-visible // panel into view triggers the IntersectionObserver above, which clears // the badge on its own once it's actually in view. function scrollToChat() { document.getElementById('chat-messages')?.scrollIntoView({ behavior: 'smooth', block: 'center' }); } // ── WS handler (called from ws.js's default dispatch branch, and from // mobile.js's own dispatcher on mobile) ───────────────────────────────── function handleWS(msg) { if (msg.type === 'chat_init') { const box = document.getElementById('chat-messages'); if (box) { box.innerHTML = ''; box.scrollTop = 0; } // Newest-first (see appendMessage) - processing history in its // stored oldest-to-newest order with prepend-at-top naturally // ends up with the newest message on top, no reversal needed. (msg.history || []).forEach(m => appendMessage(m, false)); return; } if (msg.type === 'chat_message') { appendMessage(msg.message, true); // Unread badge - only counts while the panel/modal genuinely isn't // visible right now (mobile: modal closed; desktop: scrolled out of // view or on a different tab). const visible = _isMobile() ? document.getElementById('m-chat-modal')?.style.display !== 'none' : _chatVisibleDesktop; if (!visible) { _unreadCount++; const badge = document.getElementById(_badgeId()); if (badge) { badge.hidden = false; badge.textContent = String(_unreadCount); } } return; } } // ── Render messages ─────────────────────────────────────────────────────── function appendMessage(msg, scroll) { const box = document.getElementById('chat-messages'); if (!box) return; const isSystem = msg.type === 'system'; const isMine = msg.username === window.CurrentUser?.username; const t = new Date(msg.timestamp); const timeStr = `${t.getHours().toString().padStart(2,'0')}:${t.getMinutes().toString().padStart(2,'0')}`; const color = ROLE_COLORS[msg.role] || ROLE_COLORS.viewer; const el = document.createElement('div'); el.className = 'chat-msg' + (isSystem ? ' chat-system' : '') + (isMine ? ' chat-mine' : ''); if (isSystem) { el.innerHTML = `${timeStr}${esc(msg.text)}`; } else { const badge = msg.callsign ? `${esc(msg.callsign)}` : ''; const roleDot = ``; el.innerHTML = `