/** * mc-webui Frontend Application */ // Global state let lastMessageCount = 0; let autoRefreshInterval = null; let isUserScrolling = false; let currentArchiveDate = null; // Current selected archive date (null = live) let currentChannelIdx = 0; // Current active channel (0 = Public) let availableChannels = []; // List of channels from API let lastSeenTimestamps = {}; // Track last seen message timestamp per channel let unreadCounts = {}; // Track unread message counts per channel // DM state (for badge updates on main page) let dmLastSeenTimestamps = {}; // Track last seen DM timestamp per conversation let dmUnreadCounts = {}; // Track unread DM counts per conversation /** * Global navigation function - closes offcanvas and cleans up before navigation * This prevents Bootstrap backdrop/body classes from persisting after page change */ window.navigateTo = function(url) { // Close offcanvas if open const offcanvasEl = document.getElementById('mainMenu'); if (offcanvasEl) { const offcanvas = bootstrap.Offcanvas.getInstance(offcanvasEl); if (offcanvas) { offcanvas.hide(); } } // Remove any lingering Bootstrap classes/backdrops document.body.classList.remove('modal-open', 'offcanvas-open'); document.body.style.overflow = ''; document.body.style.paddingRight = ''; // Remove any backdrops const backdrops = document.querySelectorAll('.offcanvas-backdrop, .modal-backdrop'); backdrops.forEach(backdrop => backdrop.remove()); // Navigate after cleanup setTimeout(() => { window.location.href = url; }, 100); }; // Initialize on page load document.addEventListener('DOMContentLoaded', async function() { console.log('mc-webui initialized'); // Force viewport recalculation on PWA navigation // This fixes the bottom bar visibility issue when navigating from other pages window.scrollTo(0, 0); // Trigger resize event to force browser to recalculate viewport height window.dispatchEvent(new Event('resize')); // Force reflow to ensure proper layout calculation document.body.offsetHeight; // Load last seen timestamps from server await loadLastSeenTimestampsFromServer(); await loadDmLastSeenTimestampsFromServer(); // Restore last selected channel from localStorage const savedChannel = localStorage.getItem('mc_active_channel'); if (savedChannel !== null) { currentChannelIdx = parseInt(savedChannel); } // Setup event listeners (do this early) setupEventListeners(); // Setup emoji picker setupEmojiPicker(); // CRITICAL: Load channels FIRST before anything else // This ensures channels are available for checkForUpdates() await loadChannels(); // Now load other data (can run in parallel) loadArchiveList(); loadMessages(); loadStatus(); // Setup auto-refresh AFTER channels are loaded setupAutoRefresh(); }); // Handle page restoration from cache (PWA back/forward navigation) window.addEventListener('pageshow', function(event) { if (event.persisted) { // Page was restored from cache, force viewport recalculation console.log('Page restored from cache, recalculating viewport'); window.scrollTo(0, 0); window.dispatchEvent(new Event('resize')); document.body.offsetHeight; } }); // Handle app returning from background (PWA visibility change) document.addEventListener('visibilitychange', function() { if (!document.hidden) { // App became visible again, force viewport recalculation console.log('App became visible, recalculating viewport'); setTimeout(() => { window.scrollTo(0, 0); window.dispatchEvent(new Event('resize')); document.body.offsetHeight; }, 100); } }); /** * Setup event listeners */ function setupEventListeners() { // Send message form const form = document.getElementById('sendMessageForm'); const input = document.getElementById('messageInput'); form.addEventListener('submit', function(e) { e.preventDefault(); sendMessage(); }); // Handle Enter key (send) vs Shift+Enter (new line) input.addEventListener('keydown', function(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); // Character counter input.addEventListener('input', function() { updateCharCounter(); }); // Manual refresh button document.getElementById('refreshBtn').addEventListener('click', async function() { await loadMessages(); await checkForUpdates(); // Close offcanvas menu after refresh const offcanvas = bootstrap.Offcanvas.getInstance(document.getElementById('mainMenu')); if (offcanvas) { offcanvas.hide(); } }); // Date selector (archive selection) document.getElementById('dateSelector').addEventListener('change', function(e) { currentArchiveDate = e.target.value || null; loadMessages(); // Close offcanvas menu after selecting date const offcanvas = bootstrap.Offcanvas.getInstance(document.getElementById('mainMenu')); if (offcanvas) { offcanvas.hide(); } }); // Cleanup contacts button (only exists on contact management page) const cleanupBtn = document.getElementById('cleanupBtn'); if (cleanupBtn) { cleanupBtn.addEventListener('click', function() { cleanupContacts(); }); } // Track user scrolling const container = document.getElementById('messagesContainer'); container.addEventListener('scroll', function() { const isAtBottom = container.scrollHeight - container.scrollTop <= container.clientHeight + 100; isUserScrolling = !isAtBottom; }); // Load device info when settings modal opens const settingsModal = document.getElementById('settingsModal'); settingsModal.addEventListener('show.bs.modal', function() { loadDeviceInfo(); }); // Channel selector document.getElementById('channelSelector').addEventListener('change', function(e) { currentChannelIdx = parseInt(e.target.value); localStorage.setItem('mc_active_channel', currentChannelIdx); loadMessages(); // Show notification only if we have a valid selection const selectedOption = e.target.options[e.target.selectedIndex]; if (selectedOption) { const channelName = selectedOption.text; showNotification(`Switched to channel: ${channelName}`, 'info'); } }); // Channels modal - load channels when opened const channelsModal = document.getElementById('channelsModal'); channelsModal.addEventListener('show.bs.modal', function() { loadChannelsList(); }); // Create channel form document.getElementById('createChannelForm').addEventListener('submit', async function(e) { e.preventDefault(); const name = document.getElementById('newChannelName').value.trim(); try { const response = await fetch('/api/channels', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name }) }); const data = await response.json(); if (data.success) { showNotification(`Channel "${name}" created!`, 'success'); document.getElementById('newChannelName').value = ''; document.getElementById('addChannelForm').classList.remove('show'); // Reload channels await loadChannels(); loadChannelsList(); } else { showNotification('Failed to create channel: ' + data.error, 'danger'); } } catch (error) { showNotification('Failed to create channel', 'danger'); } }); // Join channel form document.getElementById('joinChannelFormSubmit').addEventListener('submit', async function(e) { e.preventDefault(); const name = document.getElementById('joinChannelName').value.trim(); const key = document.getElementById('joinChannelKey').value.trim().toLowerCase(); // Validate: key is optional for channels starting with #, but required for others if (!name.startsWith('#') && !key) { showNotification('Channel key is required for channels not starting with #', 'warning'); return; } // Validate key format if provided if (key && !/^[a-f0-9]{32}$/.test(key)) { showNotification('Invalid key format. Must be 32 hex characters.', 'warning'); return; } try { const payload = { name: name }; if (key) { payload.key = key; } const response = await fetch('/api/channels/join', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const data = await response.json(); if (data.success) { showNotification(`Joined channel "${name}"!`, 'success'); document.getElementById('joinChannelName').value = ''; document.getElementById('joinChannelKey').value = ''; document.getElementById('joinChannelForm').classList.remove('show'); // Reload channels await loadChannels(); loadChannelsList(); } else { showNotification('Failed to join channel: ' + data.error, 'danger'); } } catch (error) { showNotification('Failed to join channel', 'danger'); } }); // Scan QR button (placeholder) document.getElementById('scanQRBtn').addEventListener('click', function() { showNotification('QR scanning feature coming soon! For now, manually enter the channel details.', 'info'); }); // Network Commands: Advert button document.getElementById('advertBtn').addEventListener('click', async function() { await executeSpecialCommand('advert'); }); // Network Commands: Flood Advert button (with confirmation) document.getElementById('floodadvBtn').addEventListener('click', async function() { if (!confirm('Flood Advertisement uses high airtime and should only be used for network recovery.\n\nAre you sure you want to proceed?')) { return; } await executeSpecialCommand('floodadv'); }); // Node Discovery Modal: Load nodes when opened const nodeDiscoveryModal = document.getElementById('nodeDiscoveryModal'); nodeDiscoveryModal.addEventListener('show.bs.modal', function() { discoverNodes(); }); // Node Discovery: Refresh button document.getElementById('refreshDiscoveryBtn').addEventListener('click', function() { discoverNodes(); }); } /** * Load messages from API */ async function loadMessages() { try { // Build URL with appropriate parameters let url = '/api/messages?limit=500'; // Add channel filter url += `&channel_idx=${currentChannelIdx}`; if (currentArchiveDate) { // Loading archive url += `&archive_date=${currentArchiveDate}`; } else { // Loading live messages - show last 7 days only url += '&days=7'; } const response = await fetch(url); const data = await response.json(); if (data.success) { displayMessages(data.messages); updateStatus('connected'); updateLastRefresh(); } else { showNotification('Error loading messages: ' + data.error, 'danger'); } } catch (error) { console.error('Error loading messages:', error); updateStatus('disconnected'); showNotification('Failed to load messages', 'danger'); } } /** * Display messages in the UI */ function displayMessages(messages) { const container = document.getElementById('messagesList'); const wasAtBottom = !isUserScrolling; // Clear loading spinner container.innerHTML = ''; if (messages.length === 0) { container.innerHTML = `
No messages yet
Send a message to get started!${escapeHtml(data.info)}`;
} else {
infoEl.innerHTML = `Error: ${escapeHtml(data.error)}`;
}
} catch (error) {
infoEl.innerHTML = 'Failed to load device info';
}
}
/**
* Cleanup inactive contacts
*/
async function cleanupContacts() {
const hours = parseInt(document.getElementById('inactiveHours').value);
if (!confirm(`Remove all contacts inactive for more than ${hours} hours?`)) {
return;
}
const btn = document.getElementById('cleanupBtn');
btn.disabled = true;
try {
const response = await fetch('/api/contacts/cleanup', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ hours: hours })
});
const data = await response.json();
if (data.success) {
showNotification(data.message, 'success');
} else {
showNotification('Cleanup failed: ' + data.error, 'danger');
}
} catch (error) {
console.error('Error cleaning contacts:', error);
showNotification('Cleanup failed', 'danger');
} finally {
btn.disabled = false;
}
}
/**
* Execute a special device command (advert, floodadv, etc.)
*/
async function executeSpecialCommand(command) {
// Get button element to disable during execution
const btnId = command === 'advert' ? 'advertBtn' : 'floodadvBtn';
const btn = document.getElementById(btnId);
if (btn) {
btn.disabled = true;
}
try {
const response = await fetch('/api/device/command', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ command: command })
});
const data = await response.json();
if (data.success) {
showNotification(data.message || `${command} sent successfully`, 'success');
} else {
showNotification(`Command failed: ${data.error}`, 'danger');
}
// Close offcanvas menu after command execution
const offcanvas = bootstrap.Offcanvas.getInstance(document.getElementById('mainMenu'));
if (offcanvas) {
offcanvas.hide();
}
} catch (error) {
console.error(`Error executing ${command}:`, error);
showNotification(`Failed to execute ${command}`, 'danger');
} finally {
if (btn) {
btn.disabled = false;
}
}
}
/**
* Setup intelligent auto-refresh
* Checks for updates regularly but only refreshes UI when new messages arrive
*/
function setupAutoRefresh() {
// Check every 10 seconds for new messages (lightweight check)
const checkInterval = 10000;
autoRefreshInterval = setInterval(async () => {
// Don't check for updates when viewing archives
if (currentArchiveDate) {
return;
}
await checkForUpdates();
await checkDmUpdates(); // Also check for DM updates
}, checkInterval);
console.log(`Intelligent auto-refresh enabled: checking every ${checkInterval / 1000}s`);
}
/**
* Update connection status indicator
*/
function updateStatus(status) {
const statusEl = document.getElementById('statusText');
const icons = {
connected: ' Connected',
disconnected: ' Disconnected',
connecting: ' Connecting...'
};
statusEl.innerHTML = icons[status] || icons.connecting;
}
/**
* Update last refresh timestamp
*/
function updateLastRefresh() {
const now = new Date();
const timeStr = now.toLocaleTimeString();
document.getElementById('lastRefresh').textContent = `Updated: ${timeStr}`;
}
/**
* Show notification toast
*/
function showNotification(message, type = 'info') {
const toastEl = document.getElementById('notificationToast');
const toastBody = toastEl.querySelector('.toast-body');
toastBody.textContent = message;
toastEl.className = `toast bg-${type} text-white`;
const toast = new bootstrap.Toast(toastEl, {
autohide: true,
delay: 1500
});
toast.show();
}
/**
* Scroll to bottom of messages
*/
function scrollToBottom() {
const container = document.getElementById('messagesContainer');
container.scrollTop = container.scrollHeight;
}
/**
* Format timestamp
*/
function formatTime(timestamp) {
const date = new Date(timestamp * 1000);
// When viewing archive, always show full date + time
if (currentArchiveDate) {
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
// When viewing live messages, use relative time
const now = new Date();
const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
// Today - show time only
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (diffDays === 1) {
// Yesterday
return 'Yesterday ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else {
// Older - show date and time
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
}
/**
* Update character counter (counts UTF-8 bytes, not characters)
*/
function updateCharCounter() {
const input = document.getElementById('messageInput');
const counter = document.getElementById('charCounter');
// Count UTF-8 bytes, not Unicode characters
const encoder = new TextEncoder();
const byteLength = encoder.encode(input.value).length;
const maxBytes = 140;
counter.textContent = `${byteLength} / ${maxBytes}`;
// Visual warning when approaching limit
if (byteLength >= maxBytes * 0.9) {
counter.classList.remove('text-muted', 'text-warning');
counter.classList.add('text-danger', 'fw-bold');
} else if (byteLength >= maxBytes * 0.75) {
counter.classList.remove('text-muted', 'text-danger');
counter.classList.add('text-warning', 'fw-bold');
} else {
counter.classList.remove('text-warning', 'text-danger', 'fw-bold');
counter.classList.add('text-muted');
}
}
/**
* Load list of available archives
*/
async function loadArchiveList() {
try {
const response = await fetch('/api/archives');
const data = await response.json();
if (data.success) {
populateDateSelector(data.archives);
} else {
console.error('Error loading archives:', data.error);
}
} catch (error) {
console.error('Error loading archive list:', error);
}
}
/**
* Populate the date selector dropdown with archive dates
*/
function populateDateSelector(archives) {
const selector = document.getElementById('dateSelector');
// Keep the "Today (Live)" option
// Remove all other options
while (selector.options.length > 1) {
selector.remove(1);
}
// Add archive dates
archives.forEach(archive => {
const option = document.createElement('option');
option.value = archive.date;
option.textContent = `${archive.date} (${archive.message_count} msgs)`;
selector.appendChild(option);
});
console.log(`Loaded ${archives.length} archives`);
}
/**
* Escape HTML to prevent XSS
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Load last seen timestamps from server
*/
async function loadLastSeenTimestampsFromServer() {
try {
const response = await fetch('/api/read_status');
const data = await response.json();
if (data.success && data.channels) {
// Convert string keys to integers for channel indices
lastSeenTimestamps = {};
for (const [key, value] of Object.entries(data.channels)) {
lastSeenTimestamps[parseInt(key)] = value;
}
console.log('Loaded channel read status from server:', lastSeenTimestamps);
} else {
console.warn('Failed to load read status from server, using empty state');
lastSeenTimestamps = {};
}
} catch (error) {
console.error('Error loading read status from server:', error);
lastSeenTimestamps = {};
}
}
/**
* Save channel read status to server
*/
async function saveChannelReadStatus(channelIdx, timestamp) {
try {
const response = await fetch('/api/read_status/mark_read', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
type: 'channel',
channel_idx: channelIdx,
timestamp: timestamp
})
});
const data = await response.json();
if (!data.success) {
console.error('Failed to save channel read status:', data.error);
}
} catch (error) {
console.error('Error saving channel read status:', error);
}
}
/**
* Update last seen timestamp for current channel
*/
async function markChannelAsRead(channelIdx, timestamp) {
lastSeenTimestamps[channelIdx] = timestamp;
unreadCounts[channelIdx] = 0;
await saveChannelReadStatus(channelIdx, timestamp);
updateUnreadBadges();
}
/**
* Check for new messages across all channels
*/
async function checkForUpdates() {
// Don't check if channels aren't loaded yet
if (!availableChannels || availableChannels.length === 0) {
console.log('[checkForUpdates] Skipping - channels not loaded yet');
return;
}
try {
// Build query with last seen timestamps
const lastSeenParam = encodeURIComponent(JSON.stringify(lastSeenTimestamps));
// Add timeout to prevent hanging
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000); // 15s timeout
const response = await fetch(`/api/messages/updates?last_seen=${lastSeenParam}`, {
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
console.warn(`[checkForUpdates] HTTP ${response.status}: ${response.statusText}`);
return;
}
const data = await response.json();
if (data.success && data.channels) {
// Update unread counts
data.channels.forEach(channel => {
unreadCounts[channel.index] = channel.unread_count;
});
// Update UI badges
updateUnreadBadges();
// If current channel has updates, refresh the view
const currentChannelUpdate = data.channels.find(ch => ch.index === currentChannelIdx);
if (currentChannelUpdate && currentChannelUpdate.has_updates) {
console.log(`New messages detected on channel ${currentChannelIdx}, refreshing...`);
await loadMessages();
}
}
} catch (error) {
if (error.name === 'AbortError') {
console.warn('[checkForUpdates] Request timeout after 15s');
} else {
console.error('[checkForUpdates] Error:', error.message || error);
}
}
}
/**
* Update unread badges on channel selector and notification bell
*/
function updateUnreadBadges() {
// Update channel selector options
const selector = document.getElementById('channelSelector');
if (selector) {
Array.from(selector.options).forEach(option => {
const channelIdx = parseInt(option.value);
const unreadCount = unreadCounts[channelIdx] || 0;
// Get base channel name (remove existing badge if any)
let channelName = option.textContent.replace(/\s*\(\d+\)$/, '');
// Add badge if there are unread messages and it's not the current channel
if (unreadCount > 0 && channelIdx !== currentChannelIdx) {
option.textContent = `${channelName} (${unreadCount})`;
} else {
option.textContent = channelName;
}
});
}
// Update notification bell
const totalUnread = Object.values(unreadCounts).reduce((sum, count) => sum + count, 0);
updateNotificationBell(totalUnread);
}
/**
* Update notification bell icon with unread count
*/
function updateNotificationBell(count) {
const bellContainer = document.getElementById('notificationBell');
if (!bellContainer) return;
const bellIcon = bellContainer.querySelector('i');
let badge = bellContainer.querySelector('.notification-badge');
if (count > 0) {
// Show badge
if (!badge) {
badge = document.createElement('span');
badge.className = 'notification-badge';
bellContainer.appendChild(badge);
}
badge.textContent = count > 99 ? '99+' : count;
badge.style.display = 'inline-block';
// Animate bell icon
if (bellIcon) {
bellIcon.classList.add('bell-ring');
setTimeout(() => bellIcon.classList.remove('bell-ring'), 1000);
}
} else {
// Hide badge
if (badge) {
badge.style.display = 'none';
}
}
}
/**
* Setup emoji picker
*/
function setupEmojiPicker() {
const emojiBtn = document.getElementById('emojiBtn');
const emojiPickerPopup = document.getElementById('emojiPickerPopup');
const messageInput = document.getElementById('messageInput');
if (!emojiBtn || !emojiPickerPopup || !messageInput) {
console.error('Emoji picker elements not found');
return;
}
// Create emoji-picker element
const picker = document.createElement('emoji-picker');
emojiPickerPopup.appendChild(picker);
// Toggle emoji picker on button click
emojiBtn.addEventListener('click', function(e) {
e.stopPropagation();
emojiPickerPopup.classList.toggle('hidden');
});
// Insert emoji into textarea when selected
picker.addEventListener('emoji-click', function(event) {
const emoji = event.detail.unicode;
const cursorPos = messageInput.selectionStart;
const textBefore = messageInput.value.substring(0, cursorPos);
const textAfter = messageInput.value.substring(messageInput.selectionEnd);
// Insert emoji at cursor position
messageInput.value = textBefore + emoji + textAfter;
// Update cursor position (after emoji)
const newCursorPos = cursorPos + emoji.length;
messageInput.setSelectionRange(newCursorPos, newCursorPos);
// Update character counter
updateCharCounter();
// Focus back on input
messageInput.focus();
// Hide picker after selection
emojiPickerPopup.classList.add('hidden');
});
// Close emoji picker when clicking outside
document.addEventListener('click', function(e) {
if (!emojiPickerPopup.contains(e.target) && e.target !== emojiBtn && !emojiBtn.contains(e.target)) {
emojiPickerPopup.classList.add('hidden');
}
});
}
/**
* Load list of available channels
*/
async function loadChannels() {
try {
console.log('[loadChannels] Fetching channels from API...');
// Add timeout to prevent hanging
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
const response = await fetch('/api/channels', {
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.log('[loadChannels] API response:', data);
if (data.success && data.channels && data.channels.length > 0) {
availableChannels = data.channels;
console.log('[loadChannels] Channels loaded:', availableChannels.length);
populateChannelSelector(data.channels);
// Check for unread messages after channels are loaded
await checkForUpdates();
} else {
console.error('[loadChannels] Error loading channels:', data.error || 'No channels returned');
// Fallback: ensure at least Public channel exists
ensurePublicChannel();
}
} catch (error) {
if (error.name === 'AbortError') {
console.error('[loadChannels] Request timeout after 10s');
} else {
console.error('[loadChannels] Exception:', error.message || error);
}
// Fallback: ensure at least Public channel exists
ensurePublicChannel();
}
}
/**
* Fallback: ensure Public channel exists in dropdown even if API fails
*/
function ensurePublicChannel() {
const selector = document.getElementById('channelSelector');
if (!selector || selector.options.length === 0) {
console.log('[ensurePublicChannel] Adding fallback Public channel');
availableChannels = [{index: 0, name: 'Public', key: ''}];
populateChannelSelector(availableChannels);
}
}
/**
* Populate channel selector dropdown
*/
function populateChannelSelector(channels) {
const selector = document.getElementById('channelSelector');
if (!selector) {
console.error('[populateChannelSelector] Channel selector element not found');
return;
}
// Validate input
if (!channels || !Array.isArray(channels) || channels.length === 0) {
console.warn('[populateChannelSelector] Invalid channels array, using fallback');
channels = [{index: 0, name: 'Public', key: ''}];
}
// Remove all options - we'll rebuild everything from API data
while (selector.options.length > 0) {
selector.remove(0);
}
// Add all channels from API (including Public at index 0)
channels.forEach(channel => {
if (channel && typeof channel.index !== 'undefined' && channel.name) {
const option = document.createElement('option');
option.value = channel.index;
option.textContent = channel.name;
selector.appendChild(option);
} else {
console.warn('[populateChannelSelector] Skipping invalid channel:', channel);
}
});
// Restore selection (use currentChannelIdx from global state)
selector.value = currentChannelIdx;
// If the saved channel doesn't exist, fall back to Public (0)
if (selector.value !== currentChannelIdx.toString()) {
console.log(`[populateChannelSelector] Channel ${currentChannelIdx} not found, falling back to Public`);
currentChannelIdx = 0;
selector.value = 0;
localStorage.setItem('mc_active_channel', '0');
}
console.log(`[populateChannelSelector] Loaded ${channels.length} channels, active: ${currentChannelIdx}`);
}
/**
* Load channels list in management modal
*/
async function loadChannelsList() {
const listEl = document.getElementById('channelsList');
listEl.innerHTML = '