/** * mc-webui Frontend Application */ // Global state let lastMessageCount = 0; 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 let channelLastMessages = {}; // channel_idx -> {preview, timestamp} let mutedChannels = new Set(); // Channel indices with muted notifications let favoriteChannels = new Set(); // Channel indices marked as favorites (always shown above non-favorites) // 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 // Map state (Leaflet) let leafletMap = null; let markersGroup = null; let contactsGeoCache = {}; // { 'contactName': { lat, lon }, ... } let contactsPubkeyMap = {}; // { 'contactName': 'full_pubkey', ... } let blockedContactNames = new Set(); // Names of blocked contacts let protectedContactPubkeys = new Set(); // Pubkeys of protected contacts let allContactsWithGps = []; // Device contacts for map filtering let allCachedContactsWithGps = []; // Cache-only contacts for map let _selfInfo = null; // Own device info (for map marker) // SocketIO state let chatSocket = null; // SocketIO connection to /chat namespace // Mentions autocomplete state let mentionsCache = []; // Cached contact list let mentionsCacheTimestamp = 0; // Cache timestamp let mentionStartPos = -1; // Position of @ in textarea let mentionSelectedIndex = 0; // Currently highlighted item let isMentionMode = false; // Is mention dropdown active // Contact type colors for map markers const CONTACT_TYPE_COLORS = { 1: '#2196F3', // COM - blue 2: '#4CAF50', // REP - green 3: '#9C27B0', // ROOM - purple 4: '#FF9800' // SENS - orange }; const CONTACT_TYPE_NAMES = { 1: 'COM', 2: 'REP', 3: 'ROOM', 4: 'SENS' }; /** * 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); }; // ============================================================================= // Leaflet Map Functions // ============================================================================= /** * Initialize Leaflet map (called once on first modal open) */ function initLeafletMap() { if (leafletMap) return; leafletMap = L.map('leafletMap').setView([52.0, 19.0], 6); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap' }).addTo(leafletMap); markersGroup = L.layerGroup().addTo(leafletMap); } /** * Show single contact on map */ function showContactOnMap(name, lat, lon) { const modalEl = document.getElementById('mapModal'); const modal = new bootstrap.Modal(modalEl); document.getElementById('mapModalTitle').textContent = name; // Hide type filter panel for single contact view const filterPanel = document.getElementById('mapTypeFilter'); if (filterPanel) filterPanel.classList.add('d-none'); const onShown = function() { initLeafletMap(); markersGroup.clearLayers(); L.marker([lat, lon]) .addTo(markersGroup) .bindPopup(`${name}`) .openPopup(); leafletMap.setView([lat, lon], 13); leafletMap.invalidateSize(); modalEl.removeEventListener('shown.bs.modal', onShown); }; modalEl.addEventListener('shown.bs.modal', onShown); modal.show(); } // Make showContactOnMap available globally (for contacts.js) window.showContactOnMap = showContactOnMap; /** * Get selected contact types from map filter badges */ function getSelectedMapTypes() { const types = []; if (document.getElementById('mapFilterCOM')?.classList.contains('active')) types.push(1); if (document.getElementById('mapFilterREP')?.classList.contains('active')) types.push(2); if (document.getElementById('mapFilterROOM')?.classList.contains('active')) types.push(3); if (document.getElementById('mapFilterSENS')?.classList.contains('active')) types.push(4); return types; } /** * Update map markers based on current filter selection */ function updateMapMarkers() { if (!leafletMap || !markersGroup) return; markersGroup.clearLayers(); const selectedTypes = getSelectedMapTypes(); const showCached = document.getElementById('mapCachedSwitch')?.checked || false; // Device contacts filtered by type const deviceKeySet = new Set(allContactsWithGps.map(c => c.public_key)); const filteredContacts = allContactsWithGps.filter(c => selectedTypes.includes(c.type)); // Cache-only contacts (not on device) filtered by type const TYPE_LABEL_TO_NUM = { 'COM': 1, 'REP': 2, 'ROOM': 3, 'SENS': 4 }; let cachedFiltered = []; if (showCached) { cachedFiltered = allCachedContactsWithGps .filter(c => !deviceKeySet.has(c.public_key)) .filter(c => { const typeNum = TYPE_LABEL_TO_NUM[c.type_label]; return typeNum ? selectedTypes.includes(typeNum) : false; }); } const allFiltered = [...filteredContacts, ...cachedFiltered]; if (allFiltered.length === 0) { leafletMap.setView([52.0, 19.0], 6); return; } const bounds = []; // Add own device marker (star shape, distinct from contacts) if (_selfInfo && _selfInfo.adv_lat && _selfInfo.adv_lon && (_selfInfo.adv_lat !== 0 || _selfInfo.adv_lon !== 0)) { const ownIcon = L.divIcon({ html: '', iconSize: [20, 20], iconAnchor: [10, 10], className: 'own-device-marker' }); L.marker([_selfInfo.adv_lat, _selfInfo.adv_lon], { icon: ownIcon }) .addTo(markersGroup) .bindPopup(`${_selfInfo.name || 'This device'}
Own device`); bounds.push([_selfInfo.adv_lat, _selfInfo.adv_lon]); } filteredContacts.forEach(c => { const color = CONTACT_TYPE_COLORS[c.type] || '#2196F3'; const typeName = CONTACT_TYPE_NAMES[c.type] || 'Unknown'; const lastSeen = c.last_advert ? formatTimeAgo(c.last_advert) : ''; L.circleMarker([c.adv_lat, c.adv_lon], { radius: 10, fillColor: color, color: '#fff', weight: 2, opacity: 1, fillOpacity: 0.8 }) .addTo(markersGroup) .bindPopup(`${c.name}
${typeName}${lastSeen ? `
Last seen: ${lastSeen}` : ''}`); bounds.push([c.adv_lat, c.adv_lon]); }); cachedFiltered.forEach(c => { const typeNum = TYPE_LABEL_TO_NUM[c.type_label] || 1; const color = CONTACT_TYPE_COLORS[typeNum] || '#2196F3'; const lastSeen = c.last_advert ? formatTimeAgo(c.last_advert) : ''; L.circleMarker([c.adv_lat, c.adv_lon], { radius: 8, fillColor: color, color: '#999', weight: 1, opacity: 0.8, fillOpacity: 0.5 }) .addTo(markersGroup) .bindPopup(`${c.name}
${c.type_label || 'Cache'} (cached)${lastSeen ? `
Last seen: ${lastSeen}` : ''}`); bounds.push([c.adv_lat, c.adv_lon]); }); if (bounds.length === 1) { leafletMap.setView(bounds[0], 13); } else if (bounds.length > 1) { leafletMap.fitBounds(bounds, { padding: [20, 20] }); } } /** * Show all contacts with GPS on map */ async function showAllContactsOnMap() { const modalEl = document.getElementById('mapModal'); const modal = new bootstrap.Modal(modalEl); document.getElementById('mapModalTitle').textContent = 'All Contacts'; // Show type filter panel const filterPanel = document.getElementById('mapTypeFilter'); if (filterPanel) filterPanel.classList.remove('d-none'); const onShown = async function() { initLeafletMap(); markersGroup.clearLayers(); try { // Fetch device info, device contacts, and cached contacts in parallel const [deviceInfoResp, deviceResp, cachedResp] = await Promise.all([ fetch('/api/device/info'), fetch('/api/contacts/detailed'), fetch('/api/contacts/cached?format=full') ]); const deviceInfoData = await deviceInfoResp.json(); const deviceData = await deviceResp.json(); const cachedData = await cachedResp.json(); // Use self info for own device marker if (deviceInfoData.success && deviceInfoData.info) { _selfInfo = deviceInfoData.info; } if (deviceData.success && deviceData.contacts) { allContactsWithGps = deviceData.contacts.filter(c => c.adv_lat && c.adv_lon && (c.adv_lat !== 0 || c.adv_lon !== 0) ); } if (cachedData.success && cachedData.contacts) { allCachedContactsWithGps = cachedData.contacts.filter(c => c.adv_lat && c.adv_lon && (c.adv_lat !== 0 || c.adv_lon !== 0) ); } updateMapMarkers(); } catch (err) { console.error('Error loading contacts for map:', err); } leafletMap.invalidateSize(); modalEl.removeEventListener('shown.bs.modal', onShown); }; // Setup filter badge listeners ['mapFilterCOM', 'mapFilterREP', 'mapFilterROOM', 'mapFilterSENS'].forEach(id => { const badge = document.getElementById(id); if (badge) { badge.onclick = () => { badge.classList.toggle('active'); updateMapMarkers(); }; } }); // Setup cached switch listener const cachedSwitch = document.getElementById('mapCachedSwitch'); if (cachedSwitch) { cachedSwitch.onchange = () => updateMapMarkers(); } modalEl.addEventListener('shown.bs.modal', onShown); modal.show(); } /** * Load contacts geo cache for message map buttons */ async function loadContactsGeoCache() { try { // Load detailed (device) and cached contacts in parallel const [detailedResp, cachedResp] = await Promise.all([ fetch('/api/contacts/detailed'), fetch('/api/contacts/cached?format=full') ]); const detailedData = await detailedResp.json(); const cachedData = await cachedResp.json(); contactsGeoCache = {}; contactsPubkeyMap = {}; // Process device contacts if (detailedData.success && detailedData.contacts) { detailedData.contacts.forEach(c => { if (c.adv_lat && c.adv_lon && (c.adv_lat !== 0 || c.adv_lon !== 0)) { contactsGeoCache[c.name] = { lat: c.adv_lat, lon: c.adv_lon }; } if (c.name && c.public_key) { contactsPubkeyMap[c.name] = c.public_key; } }); } // Process cached contacts (fills gaps for contacts not on device) if (cachedData.success && cachedData.contacts) { cachedData.contacts.forEach(c => { if (!contactsGeoCache[c.name] && c.adv_lat && c.adv_lon && (c.adv_lat !== 0 || c.adv_lon !== 0)) { contactsGeoCache[c.name] = { lat: c.adv_lat, lon: c.adv_lon }; } if (c.name && c.public_key && !contactsPubkeyMap[c.name]) { contactsPubkeyMap[c.name] = c.public_key; } }); } console.log(`Loaded geo cache for ${Object.keys(contactsGeoCache).length} contacts, pubkey map for ${Object.keys(contactsPubkeyMap).length}`); } catch (err) { console.error('Error loading contacts geo cache:', err); } } async function loadBlockedNames() { try { const resp = await fetch('/api/contacts/blocked-names'); const data = await resp.json(); if (data.success) { blockedContactNames = new Set(data.names); } } catch (err) { console.error('Error loading blocked names:', err); } } async function loadProtectedPubkeys() { try { const resp = await fetch('/api/contacts/protected'); const data = await resp.json(); if (data.success) { protectedContactPubkeys = new Set((data.protected_contacts || []).map(pk => pk.toLowerCase())); } } catch (err) { console.error('Error loading protected contacts:', err); } } function isContactProtectedByName(senderName) { const pubkey = contactsPubkeyMap[senderName]; return pubkey && protectedContactPubkeys.has(pubkey.toLowerCase()); } // Initialize on page load /** * Connect to SocketIO /chat namespace for real-time message updates */ function connectChatSocket() { if (typeof io === 'undefined') { console.warn('SocketIO not available, falling back to polling only'); return; } const wsUrl = window.location.origin; chatSocket = io(wsUrl + '/chat', { transports: ['polling'], upgrade: false, reconnection: true, reconnectionDelay: 2000, reconnectionDelayMax: 10000, }); chatSocket.on('connect', () => { console.log('SocketIO connected to /chat'); }); chatSocket.on('connect_error', (err) => { console.error('SocketIO /chat connect error:', err.message); }); // Real-time new channel message chatSocket.on('new_message', (data) => { // Filter blocked contacts in real-time if (data.type === 'channel' && blockedContactNames.has(data.sender)) return; if (data.type === 'dm' && blockedContactNames.has(data.sender)) return; if (data.type === 'channel') { // Update last-message preview/time for this channel (for sidebar/dropdown) if (typeof data.content === 'string' && typeof data.timestamp === 'number') { channelLastMessages[data.channel_idx] = { preview: makeChannelPreview(data.content), timestamp: data.timestamp }; } // Reorder: move this channel to the top of its tier in sidebar + dropdown moveChannelToTopOfTier(data.channel_idx); // Update unread count for this channel if (data.channel_idx !== currentChannelIdx) { unreadCounts[data.channel_idx] = (unreadCounts[data.channel_idx] || 0) + 1; updateUnreadBadges(); checkAndNotify(); } else if (!currentArchiveDate) { // Refresh sidebar preview even for the active channel updateChannelSidebarBadges(); // Skip own messages — already appended optimistically on send if (data.is_own) return; // Current channel and live view — append message directly (no full reload) appendMessageFromSocket(data); } } else if (data.type === 'dm') { // Update DM badge on main page checkDmUpdates(); } }); // Real-time echo data — update metadata for specific messages (no full reload) let echoRefreshTimer = null; const targetedRefreshIds = new Set(); // msg_ids that must bypass the "already has route" skip chatSocket.on('echo', (data) => { if (currentArchiveDate) return; // Don't refresh archive view // When the backend tags the echo with a specific msg_id (e.g. echoes // arriving after a resend), record it so the debounced refresh // re-fetches that message's meta even if its badge is already drawn. if (data && typeof data.msg_id === 'number') { targetedRefreshIds.add(data.msg_id); } // Debounce: wait for echoes to settle, then update affected messages if (echoRefreshTimer) clearTimeout(echoRefreshTimer); echoRefreshTimer = setTimeout(() => { echoRefreshTimer = null; const ids = Array.from(targetedRefreshIds); targetedRefreshIds.clear(); refreshMessagesMeta(ids); }, 2000); }); // Real-time pending contact — update badge (unless suppressed by user setting) chatSocket.on('pending_contact', () => { if (window.contactsSettings?.suppress_advert_notifications) return; updatePendingContactsBadge(); }); // Real-time device status chatSocket.on('device_status', (data) => { const statusEl = document.getElementById('connectionStatus'); if (statusEl) { statusEl.className = data.connected ? 'connection-status connected' : 'connection-status disconnected'; statusEl.textContent = data.connected ? 'Connected' : 'Disconnected'; } }); } document.addEventListener('DOMContentLoaded', async function() { console.log('mc-webui initialized'); const initStart = performance.now(); // 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; // Restore last selected channel from localStorage (sync, fast) const savedChannel = localStorage.getItem('mc_active_channel'); if (savedChannel !== null) { currentChannelIdx = parseInt(savedChannel); } // Setup event listeners and emoji picker early (sync, fast) setupEventListeners(); setupEmojiPicker(); // OPTIMIZATION: Load timestamps in parallel (both are independent API calls) console.log('[init] Loading timestamps in parallel...'); await Promise.all([ loadLastSeenTimestampsFromServer(), loadDmLastSeenTimestampsFromServer() ]); // Load channels (required before loading messages) // NOTE: checkForUpdates() was removed from loadChannels() to speed up init console.log('[init] Loading channels...'); await loadChannels(); loadChannelScopes(); // non-blocking, populates status-bar region pill // OPTIMIZATION: Load messages immediately, don't wait for geo cache // Map buttons will appear once geo cache loads (non-blocking UX improvement) console.log('[init] Loading messages (priority) and geo cache (background)...'); // Start these in parallel - messages are critical, geo cache can load async const messagesPromise = loadMessages(); const geoCachePromise = loadContactsGeoCache(); // Non-blocking, Map buttons update when ready const blockedPromise = loadBlockedNames(); // Non-blocking, for real-time filtering const protectedPromise = loadProtectedPubkeys(); // Non-blocking, for disabling ignore/block on protected // Also start archive list loading in parallel loadArchiveList(); // Wait for messages to display (this is what the user wants to see ASAP) await messagesPromise; console.log(`[init] Messages loaded in ${(performance.now() - initStart).toFixed(0)}ms`); // Initial badge updates (fast, sync-ish) updatePendingContactsBadge(); loadStatus(); // Map button in menu const mapBtn = document.getElementById('mapBtn'); if (mapBtn) { mapBtn.addEventListener('click', () => { // Close offcanvas first const offcanvas = bootstrap.Offcanvas.getInstance(document.getElementById('mainMenu')); if (offcanvas) offcanvas.hide(); showAllContactsOnMap(); }); } // Update notification toggle UI updateNotificationToggleUI(); // Initialize filter functionality initializeFilter(); // Apply Quick Access / Main Menu placements before FAB init so visibility is set early applyItemPlacements(); initializeItemPlacementSettings(); // Sidebar breakpoint: re-apply (covers no-localStorage case) and wire up settings UI + resize listener applySidebarBreakpoint(); initializeSidebarBreakpointSettings(); // Initialize FAB toggle initializeFabToggle(); // Connect SocketIO for real-time updates connectChatSocket(); console.log(`[init] UI ready in ${(performance.now() - initStart).toFixed(0)}ms`); // DEFERRED: Check for updates AFTER messages are displayed // This updates the unread badges without blocking initial load checkForUpdates(); // No await - runs in background // Geo cache loads in background - once loaded, re-render messages to show Map buttons geoCachePromise.then(() => { console.log(`[init] Geo cache loaded in ${(performance.now() - initStart).toFixed(0)}ms, refreshing messages for Map buttons`); // Re-render messages now that geo cache is available (Map buttons will appear) loadMessages(); }); }); // 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); // Clear app badge when user returns to app if ('clearAppBadge' in navigator) { navigator.clearAppBadge().catch((error) => { console.error('Error clearing app badge on visibility:', error); }); } } }); /** * 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(); }); // Setup mentions autocomplete setupMentionsAutocomplete(); // Check for app updates button const checkUpdateBtn = document.getElementById('checkUpdateBtn'); if (checkUpdateBtn) { checkUpdateBtn.addEventListener('click', async function() { await checkForAppUpdates(); }); } // 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 and show/hide scroll-to-bottom button const container = document.getElementById('messagesContainer'); const scrollToBottomBtn = document.getElementById('scrollToBottomBtn'); container.addEventListener('scroll', function() { const isAtBottom = container.scrollHeight - container.scrollTop <= container.clientHeight + 100; isUserScrolling = !isAtBottom; // Show/hide scroll-to-bottom button if (scrollToBottomBtn) { if (isAtBottom) { scrollToBottomBtn.classList.remove('visible'); } else { scrollToBottomBtn.classList.add('visible'); } } }); // Scroll-to-bottom button click handler if (scrollToBottomBtn) { scrollToBottomBtn.addEventListener('click', function() { scrollToBottom(); scrollToBottomBtn.classList.remove('visible'); }); } // Load device info when modal opens const deviceInfoModal = document.getElementById('deviceInfoModal'); deviceInfoModal.addEventListener('show.bs.modal', function() { loadDeviceInfo(); }); // Channel selector (custom searchable picker, visible on mobile) const channelInput = document.getElementById('channelSelectorInput'); const channelDropdown = document.getElementById('channelSelectorDropdown'); const channelWrapper = document.getElementById('channelSelectorWrapper'); if (channelInput && channelDropdown) { channelInput.addEventListener('focus', () => { channelInput.value = ''; renderChannelDropdownItems(''); channelDropdown.style.display = 'block'; }); channelInput.addEventListener('input', () => { renderChannelDropdownItems(channelInput.value); channelDropdown.style.display = 'block'; }); // Prevent dropdown mousedown from stealing focus/closing dropdown channelDropdown.addEventListener('mousedown', (e) => { e.preventDefault(); }); // Close dropdown when clicking outside the wrapper document.addEventListener('mousedown', (e) => { if (channelWrapper && !channelWrapper.contains(e.target)) { if (channelDropdown.style.display !== 'none') { channelDropdown.style.display = 'none'; updateChannelInputDisplay(); } } }); channelInput.addEventListener('keydown', (e) => { if (e.key === 'Escape') { channelDropdown.style.display = 'none'; updateChannelInputDisplay(); channelInput.blur(); } else if (e.key === 'Enter') { e.preventDefault(); const active = channelDropdown.querySelector('.channel-selector-item.active[data-channel-idx]'); const target = active || channelDropdown.querySelector('.channel-selector-item[data-channel-idx]'); if (target) target.click(); } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); const items = Array.from(channelDropdown.querySelectorAll('.channel-selector-item[data-channel-idx]')); if (items.length === 0) return; const activeIdx = items.findIndex(el => el.classList.contains('active')); items.forEach(el => el.classList.remove('active')); let nextIdx; if (e.key === 'ArrowDown') { nextIdx = activeIdx < 0 ? 0 : Math.min(activeIdx + 1, items.length - 1); } else { nextIdx = activeIdx <= 0 ? 0 : activeIdx - 1; } items[nextIdx].classList.add('active'); items[nextIdx].scrollIntoView({ block: 'nearest' }); } }); } // 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 submitBtn = this.querySelector('button[type="submit"]'); if (submitBtn && submitBtn.disabled) return; // in-flight guard const name = document.getElementById('newChannelName').value.trim(); if (submitBtn) submitBtn.disabled = true; 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) { const msg = data.already_existed ? `Channel "${name}" already exists.` : `Channel "${name}" created!`; showNotification(msg, data.already_existed ? 'info' : 'success'); // Show warning if returned (e.g., exceeding soft limit of 7 channels) if (data.warning) { setTimeout(() => { showNotification(data.warning, 'warning'); }, 2000); // Show after success message } 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'); } finally { if (submitBtn) submitBtn.disabled = false; } }); // Join channel form document.getElementById('joinChannelFormSubmit').addEventListener('submit', async function(e) { e.preventDefault(); const submitBtn = this.querySelector('button[type="submit"]'); if (submitBtn && submitBtn.disabled) return; // in-flight guard 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; } if (submitBtn) submitBtn.disabled = true; 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) { const msg = data.already_existed ? `Already joined channel "${name}".` : `Joined channel "${name}"!`; showNotification(msg, data.already_existed ? 'info' : 'success'); // Show warning if returned (e.g., exceeding soft limit of 7 channels) if (data.warning) { setTimeout(() => { showNotification(data.warning, 'warning'); }, 2000); // Show after success message } 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'); } finally { if (submitBtn) submitBtn.disabled = false; } }); // 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'); }); // FAB equivalents for menu actions (visible only when item placement = "fab") document.getElementById('fab-advert')?.addEventListener('click', async () => { await executeSpecialCommand('advert'); }); document.getElementById('fab-floodadvert')?.addEventListener('click', async () => { 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'); }); document.getElementById('fab-map')?.addEventListener('click', () => { showAllContactsOnMap(); }); // Menu equivalent for filter FAB: close offcanvas first, then open filter bar document.getElementById('menu-filter')?.addEventListener('click', () => { const oc = document.getElementById('mainMenu'); const inst = bootstrap.Offcanvas.getInstance(oc) || bootstrap.Offcanvas.getOrCreateInstance(oc); oc.addEventListener('hidden.bs.offcanvas', () => openFilterBar(), { once: true }); inst.hide(); }); // Notification toggle const notificationsToggle = document.getElementById('notificationsToggle'); if (notificationsToggle) { notificationsToggle.addEventListener('click', handleNotificationToggle); } } /** * 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'; } // Add timeout to prevent hanging spinner const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 15000); const response = await fetch(url, { signal: controller.signal }); clearTimeout(timeoutId); const data = await response.json(); if (data.success) { displayMessages(data.messages); updateStatus('connected'); updateLastRefresh(); updateRegionIndicator(); } else { showNotification('Error loading messages: ' + data.error, 'danger'); clearLoadingSpinner(); } } catch (error) { console.error('Error loading messages:', error); updateStatus('disconnected'); clearLoadingSpinner(); if (error.name === 'AbortError') { showNotification('Loading messages timed out — retrying...', 'warning'); setTimeout(loadMessages, 2000); } else { showNotification('Failed to load messages', 'danger'); } } } function clearLoadingSpinner() { const container = document.getElementById('messagesList'); if (container && container.querySelector('.spinner-border')) { container.innerHTML = `

Could not load messages

Will retry automatically
`; } } /** * 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!
`; return; } // Render each message (skip blocked senders client-side as extra safety) messages.forEach(msg => { if (!msg.is_own && blockedContactNames.has(msg.sender)) return; const messageEl = createMessageElement(msg); container.appendChild(messageEl); }); // Auto-scroll to bottom if user wasn't scrolling if (wasAtBottom) { scrollToBottom(); } lastMessageCount = messages.length; // Mark current channel as read (update last seen timestamp to latest message) if (messages.length > 0 && !currentArchiveDate) { const latestTimestamp = Math.max(...messages.map(m => m.timestamp)); markChannelAsRead(currentChannelIdx, latestTimestamp); } // Backfill raw-resend buttons on own messages — handles the case where // displayMessages ran before window.deviceCaps was populated (initial // page load race), and the channel-switch case where createMessageElement // does render the button but we want belt-and-suspenders for any path // (e.g. archive view) that might bypass it. injectRawResendButtonsForVisibleMessages(); // Re-apply filter if active clearFilterState(); } /** * Append a single message from SocketIO event (no full reload). * Removes the "empty state" placeholder if present. */ function appendMessageFromSocket(data) { const container = document.getElementById('messagesList'); // Remove empty-state placeholder if present const emptyState = container.querySelector('.empty-state'); if (emptyState) emptyState.remove(); // Build a msg object compatible with createMessageElement const msg = { id: data.id || null, sender: data.sender || '', content: data.content || '', timestamp: data.timestamp || Math.floor(Date.now() / 1000), is_own: !!data.is_own, channel_idx: data.channel_idx, snr: data.snr ?? null, path_len: data.path_len ?? null, hop_count: data.hop_count ?? null, path_hash_size: data.path_hash_size ?? 1, echo_paths: [], echo_snrs: [], echo_hash_sizes: [], packet_hash: data.packet_hash || null, pkt_payload: data.pkt_payload || null, txt_type: data.txt_type || 0, }; const messageEl = createMessageElement(msg); container.appendChild(messageEl); // Auto-scroll to bottom if user wasn't scrolling up if (!isUserScrolling) { scrollToBottom(); } // Update last message count and read status lastMessageCount++; markChannelAsRead(currentChannelIdx, msg.timestamp); } /** * Refresh metadata (SNR, hops, route, analyzer) for messages missing it. * Fetches /api/messages//meta for each incomplete message, updates DOM in-place. */ async function refreshMessagesMeta(forceIds = []) { const container = document.getElementById('messagesList'); if (!container) return; const forced = new Set((forceIds || []).map(String)); // Find message wrappers that don't have full metadata yet const wrappers = container.querySelectorAll('.message-wrapper[data-msg-id]'); for (const wrapper of wrappers) { const msgId = wrapper.dataset.msgId; if (!msgId || msgId.startsWith('_pending_')) continue; // Skip messages that already have meta info with route/analyzer data, // unless this msg_id was explicitly forced (e.g. by post-resend echoes // that need the existing badge re-fetched to extend the repeater list). if (!forced.has(msgId)) { const metaEl = wrapper.querySelector('.message-meta'); const actionsEl = wrapper.querySelector('.message-actions'); const hasRoute = metaEl && metaEl.querySelector('.path-info'); const hasAnalyzer = actionsEl && actionsEl.querySelector('[title="View in Analyzer"]'); if (hasRoute && hasAnalyzer) continue; } try { const resp = await fetch(`/api/messages/${msgId}/meta`); const meta = await resp.json(); if (!meta.success) continue; updateMessageMetaDOM(wrapper, meta); } catch (e) { console.error(`Error fetching meta for msg #${msgId}:`, e); } } } /** * Update metadata and action buttons in-place for a single message wrapper. */ function updateMessageMetaDOM(wrapper, meta) { const isOwn = wrapper.classList.contains('own'); // Build meta info string let metaParts = []; const displaySnr = (meta.snr !== undefined && meta.snr !== null) ? meta.snr : (meta.echo_snrs && meta.echo_snrs.length > 0) ? meta.echo_snrs[0] : null; if (displaySnr !== null) { metaParts.push(`SNR: ${displaySnr.toFixed(1)} dB`); } const hopCount = meta.hop_count ?? (meta.path_len !== null && meta.path_len !== undefined ? (meta.path_len & 0x3F) : null); if (hopCount !== null) { metaParts.push(`Hops: ${hopCount}`); } // Build paths from echo data let paths = null; if (meta.echo_paths && meta.echo_paths.length > 0) { paths = meta.echo_paths.map((p, i) => ({ path: p, snr: meta.echo_snrs ? meta.echo_snrs[i] : null, hash_size: meta.echo_hash_sizes ? meta.echo_hash_sizes[i] : (meta.path_hash_size || 1), })); } if (paths && paths.length > 0) { const firstPath = paths[0]; const chunkLen = (firstPath.hash_size || 1) * 2; const segments = []; if (firstPath.path) { for (let i = 0; i < firstPath.path.length; i += chunkLen) { segments.push(firstPath.path.substring(i, i + chunkLen).toUpperCase()); } } const shortPath = segments.length > 4 ? `${segments[0]}\u2192...\u2192${segments[segments.length - 1]}` : segments.join('\u2192'); const pathsData = encodeURIComponent(JSON.stringify(paths)); const routeLabel = paths.length > 1 ? `Route (${paths.length})` : 'Route'; metaParts.push(`${routeLabel}: ${shortPath}`); } const metaInfo = metaParts.join(' | '); if (!isOwn) { // Update or insert .message-meta div const msgDiv = wrapper.querySelector('.message.other'); if (!msgDiv) return; let metaEl = msgDiv.querySelector('.message-meta'); if (metaInfo) { if (!metaEl) { metaEl = document.createElement('div'); metaEl.className = 'message-meta'; const actionsEl = msgDiv.querySelector('.message-actions'); msgDiv.insertBefore(metaEl, actionsEl); } metaEl.innerHTML = metaInfo; } // Add analyzer button if not already present if (meta.packet_hash) { const actionsEl = msgDiv.querySelector('.message-actions'); if (actionsEl && !actionsEl.querySelector('[title="View in Analyzer"]')) { const ignoreBtn = actionsEl.querySelector('[title^="Ignore"]'); const analyzerBtn = document.createElement('button'); analyzerBtn.className = 'btn btn-outline-secondary btn-msg-action'; analyzerBtn.setAttribute('onclick', `openMessageAnalyzer('${meta.packet_hash}')`); analyzerBtn.title = 'View in Analyzer'; analyzerBtn.innerHTML = ''; actionsEl.insertBefore(analyzerBtn, ignoreBtn); } } } else { // Own messages: update echo badge and analyzer button const msgDiv = wrapper.querySelector('.message.own'); if (!msgDiv) return; // Update echo badge if (meta.echo_paths && meta.echo_paths.length > 0) { // For own messages path_hash_size is null — use hash_size from echoes const echoHashSize = (meta.echo_hash_sizes && meta.echo_hash_sizes.length > 0) ? meta.echo_hash_sizes[0] : (meta.path_hash_size || 1); const echoPrefixLen = echoHashSize * 2; const echoPaths = [...new Set(meta.echo_paths.map(p => p.substring(0, echoPrefixLen).toUpperCase()))]; const echoCount = echoPaths.length; const pathDisplay = echoPaths.length > 0 ? ` (${echoPaths.join(', ')})` : ''; const actionsEl = msgDiv.querySelector('.message-actions'); if (actionsEl) { let badge = actionsEl.querySelector('.echo-badge'); if (!badge) { badge = document.createElement('span'); badge.className = 'echo-badge'; actionsEl.insertBefore(badge, actionsEl.firstChild); } badge.title = `Heard by ${echoCount} repeater(s): ${echoPaths.join(', ')}`; badge.innerHTML = ` ${echoCount}${pathDisplay}`; } } // Add analyzer button if (meta.packet_hash) { const actionsEl = msgDiv.querySelector('.message-actions'); if (actionsEl && !actionsEl.querySelector('[title="View in Analyzer"]')) { // Anchor analyzer before whichever action button comes first // (post-rename: "Edit message"; legacy renders may still say "Resend") const anchor = actionsEl.querySelector('[title="Edit message"]') || actionsEl.querySelector('[title="Resend"]') || actionsEl.querySelector('[title^="Resend"]'); const analyzerBtn = document.createElement('button'); analyzerBtn.className = 'btn btn-outline-secondary btn-msg-action'; analyzerBtn.setAttribute('onclick', `openMessageAnalyzer('${meta.packet_hash}')`); analyzerBtn.title = 'View in Analyzer'; analyzerBtn.innerHTML = ''; if (anchor) actionsEl.insertBefore(analyzerBtn, anchor); else actionsEl.appendChild(analyzerBtn); } } // Add raw-resend button if supported and missing. The initial render // path also injects this, but messages loaded from history before // window.deviceCaps was populated by loadStatus get it here on the // next echo-driven meta refresh. const msgId = wrapper.dataset.msgId; if (window.deviceCaps?.supports_raw_resend && msgId) { const actionsEl = msgDiv.querySelector('.message-actions'); if (actionsEl && !actionsEl.querySelector('.btn-raw-resend')) { const rawBtn = document.createElement('button'); rawBtn.className = 'btn btn-outline-secondary btn-msg-action btn-raw-resend'; rawBtn.setAttribute('onclick', `resendChannelMessageRaw(${msgId}, this)`); rawBtn.title = 'Resend (rebroadcast same packet so unreached repeaters can pick it up)'; rawBtn.innerHTML = ''; actionsEl.appendChild(rawBtn); } } } } /** * Create message DOM element */ function createMessageElement(msg) { const wrapper = document.createElement('div'); wrapper.className = `message-wrapper ${msg.is_own ? 'own' : 'other'}`; if (msg.id) wrapper.dataset.msgId = msg.id; const time = formatTime(msg.timestamp); // Build paths from echo data if not already present if (!msg.paths && msg.echo_paths && msg.echo_paths.length > 0) { msg.paths = msg.echo_paths.map((p, i) => ({ path: p, snr: msg.echo_snrs ? msg.echo_snrs[i] : null, hash_size: msg.echo_hash_sizes ? msg.echo_hash_sizes[i] : (msg.path_hash_size || 1), })); } let metaParts = []; // Use message SNR, or fall back to first echo path SNR const displaySnr = (msg.snr !== undefined && msg.snr !== null) ? msg.snr : (msg.echo_snrs && msg.echo_snrs.length > 0) ? msg.echo_snrs[0] : null; if (displaySnr !== null) { metaParts.push(`SNR: ${displaySnr.toFixed(1)} dB`); } const msgHopCount = msg.hop_count ?? (msg.path_len !== null && msg.path_len !== undefined ? (msg.path_len & 0x3F) : null); if (msgHopCount !== null) { metaParts.push(`Hops: ${msgHopCount}`); } if (msg.paths && msg.paths.length > 0) { // Show first path inline (shortest/first arrival) const firstPath = msg.paths[0]; const chunkLen = (firstPath.hash_size || 1) * 2; const segments = []; if (firstPath.path) { for (let i = 0; i < firstPath.path.length; i += chunkLen) { segments.push(firstPath.path.substring(i, i + chunkLen).toUpperCase()); } } const shortPath = segments.length > 4 ? `${segments[0]}\u2192...\u2192${segments[segments.length - 1]}` : segments.join('\u2192'); const pathsData = encodeURIComponent(JSON.stringify(msg.paths)); const routeLabel = msg.paths.length > 1 ? `Route (${msg.paths.length})` : 'Route'; metaParts.push(`${routeLabel}: ${shortPath}`); } const metaInfo = metaParts.join(' | '); if (msg.is_own) { // Own messages: right-aligned, no avatar // Echo badge shows unique repeaters that heard the message + their path codes // For own messages path_hash_size is null — use hash_size from echoes const echoHS = (msg.echo_hash_sizes && msg.echo_hash_sizes.length > 0) ? msg.echo_hash_sizes[0] : (msg.path_hash_size || 1); const echoPrefixLen2 = echoHS * 2; const echoPaths = [...new Set((msg.echo_paths || []).map(p => p.substring(0, echoPrefixLen2).toUpperCase()))]; const echoCount = echoPaths.length; const pathDisplay = echoPaths.length > 0 ? ` (${echoPaths.join(', ')})` : ''; const echoDisplay = echoCount > 0 ? ` ${echoCount}${pathDisplay} ` : ''; wrapper.innerHTML = `
${processMessageContent(msg.content)}
${echoDisplay} ${msg.packet_hash ? ` ` : ''} ${window.deviceCaps?.supports_raw_resend && typeof msg.id === 'number' ? ` ` : ''}
`; } else { // Other messages: left-aligned with avatar const avatar = generateAvatar(msg.sender); const avatarStyle = avatar.isEmoji ? `border-color: ${avatar.color};` : `background-color: ${avatar.color};`; wrapper.innerHTML = `
${avatar.content}
${escapeHtml(msg.sender)} ${time}
${processMessageContent(msg.content)}
${metaInfo ? `
${metaInfo}
` : ''}
${contactsGeoCache[msg.sender] ? ` ` : ''} ${msg.packet_hash ? ` ` : ''} ${contactsPubkeyMap[msg.sender] && !isContactProtectedByName(msg.sender) ? ` ` : ''} ${!isContactProtectedByName(msg.sender) ? ` ` : ''}
`; } return wrapper; } /** * Send a message */ async function sendMessage() { const input = document.getElementById('messageInput'); const text = input.value.trim(); if (!text) return; const sendBtn = document.getElementById('sendBtn'); sendBtn.disabled = true; // Optimistic append: show sent message immediately before API round-trip input.value = ''; updateCharCounter(); const optimisticId = '_pending_' + Date.now(); appendMessageFromSocket({ id: optimisticId, sender: window.MC_CONFIG?.deviceName || 'Me', content: text, timestamp: Math.floor(Date.now() / 1000), is_own: true, channel_idx: currentChannelIdx, }); try { const response = await fetch('/api/messages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: text, channel_idx: currentChannelIdx }) }); const data = await response.json(); if (data.success) { showNotification('Message sent', 'success'); // Replace optimistic ID with real DB id so echo WebSocket updates work if (data.id) { const wrapper = document.querySelector(`.message-wrapper[data-msg-id="${optimisticId}"]`); if (wrapper) { wrapper.dataset.msgId = data.id; // Inject the post-send action buttons (analyzer, raw resend) // now — refreshMessagesMeta would otherwise skip this message // until the first echo arrives, which never happens on // channels with no repeaters in range. refreshMessagesMeta([data.id]); } } // Use server timestamp to prevent poll-triggered reload due to clock skew if (data.timestamp) { markChannelAsRead(currentChannelIdx, data.timestamp); } } else { showNotification('Failed to send: ' + data.error, 'danger'); } } catch (error) { console.error('Error sending message:', error); showNotification('Failed to send message', 'danger'); } finally { sendBtn.disabled = false; input.focus(); } } /** * Reply to a user */ function replyTo(username) { const input = document.getElementById('messageInput'); input.value = `@[${username}] `; updateCharCounter(); input.focus(); } /** * Truncate text to maxBytes UTF-8 bytes, respecting multi-byte characters. * @returns {string} truncated text (without "..." suffix) */ function truncateToBytes(text, maxBytes) { const encoder = new TextEncoder(); if (encoder.encode(text).length <= maxBytes) return text; let truncated = ''; let byteCount = 0; for (const char of text) { const charBytes = encoder.encode(char).length; if (byteCount + charBytes > maxBytes) break; truncated += char; byteCount += charBytes; } return truncated; } /** * Insert a quote into the message input. */ function insertQuote(username, quotedText) { const input = document.getElementById('messageInput'); input.value = `@[${username}] »${quotedText}« `; updateCharCounter(); input.focus(); } /** * Quote a user's message — shows a dialog to choose full or truncated quote. * @param {string} username - Username to mention * @param {string} content - Original message content to quote */ function quoteTo(username, content) { const encoder = new TextEncoder(); const contentBytes = encoder.encode(content).length; const maxBytes = chatSettingsCache.quote_max_bytes || CHAT_SETTINGS_DEFAULTS.quote_max_bytes; // If message fits within limit, insert directly — no dialog needed if (contentBytes <= maxBytes) { insertQuote(username, content); return; } // Show quote dialog const preview = truncateToBytes(content, 60); document.getElementById('quotePreview').textContent = preview.length < content.length ? preview + '...' : preview; document.getElementById('quoteBytesInput').value = maxBytes; const modal = new bootstrap.Modal(document.getElementById('quoteModal')); // Clean up old listeners by replacing buttons const fullBtn = document.getElementById('quoteFullBtn'); const truncBtn = document.getElementById('quoteTruncatedBtn'); const newFullBtn = fullBtn.cloneNode(true); const newTruncBtn = truncBtn.cloneNode(true); fullBtn.parentNode.replaceChild(newFullBtn, fullBtn); truncBtn.parentNode.replaceChild(newTruncBtn, truncBtn); newFullBtn.addEventListener('click', () => { modal.hide(); insertQuote(username, content); }); newTruncBtn.addEventListener('click', () => { modal.hide(); const customBytes = parseInt(document.getElementById('quoteBytesInput').value, 10) || maxBytes; const truncated = truncateToBytes(content, customBytes); insertQuote(username, truncated + '...'); }); modal.show(); } /** * Edit-message helper: paste an own message's content back into the composer * so the user can tweak it before sending. NOT a true resend — every press * produces a new packet hash. The arrow-repeat button next to this one does * the actual raw resend (resendChannelMessageRaw). * @param {string} content - Message content to paste */ function resendMessage(content) { const input = document.getElementById('messageInput'); input.value = content; updateCharCounter(); input.focus(); } /** * Raw resend: re-broadcast the exact same packet bytes so repeaters that * already forwarded it dedupe via packet-hash, while unreached repeaters * pick it up. Backend returns 400 if the message has no raw_packet snapshot * (sent before this feature shipped) or if the firmware is too old. * * @param {number} msgId - channel_messages.id * @param {HTMLElement} btn - the clicked button, used to spin the icon during the call */ async function resendChannelMessageRaw(msgId, btn) { if (!msgId || btn?.dataset.busy === '1') return; const icon = btn?.querySelector('i'); if (btn) { btn.dataset.busy = '1'; btn.disabled = true; if (icon) icon.classList.add('spin'); } try { const resp = await fetch(`/api/messages/${msgId}/resend`, { method: 'POST' }); const data = await resp.json().catch(() => ({})); if (resp.ok && data.success) { showNotification(`Resent (${data.bytes ?? '?'} B) — waiting for echoes…`, 'info'); } else { showNotification(`Resend failed: ${data.error || resp.statusText}`, 'danger'); } } catch (err) { showNotification(`Resend network error: ${err.message || err}`, 'danger'); } finally { if (btn) { btn.dataset.busy = '0'; btn.disabled = false; if (icon) icon.classList.remove('spin'); } } } async function ignoreContactFromChat(pubkey) { try { const response = await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/ignore`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ignored: true }) }); const data = await response.json(); if (data.success) { showNotification(data.message, 'info'); } else { showNotification('Failed: ' + data.error, 'danger'); } } catch (err) { showNotification('Network error', 'danger'); } } async function blockContactFromChat(senderName) { if (!confirm(`Block ${senderName}? Their messages will be hidden from chat.`)) return; try { const pubkey = contactsPubkeyMap[senderName]; let response; if (pubkey) { // Block by pubkey (known contact) response = await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/block`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ blocked: true }) }); } else { // Block by name (bot/unknown contact) response = await fetch('/api/contacts/block-name', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: senderName, blocked: true }) }); } const data = await response.json(); if (data.success) { showNotification(data.message, 'warning'); // Update blocked names then reload messages to hide blocked sender await loadBlockedNames(); await loadMessages(); } else { showNotification('Failed: ' + data.error, 'danger'); } } catch (err) { console.error('Error blocking contact from chat:', err); showNotification('Network error', 'danger'); } } /** * Show paths popup on tap (mobile-friendly, shows all routes) */ function showPathsPopup(element, encodedPaths) { // Remove any existing popup const existing = document.querySelector('.path-popup'); if (existing) existing.remove(); const paths = JSON.parse(decodeURIComponent(encodedPaths)); const popup = document.createElement('div'); popup.className = 'path-popup'; paths.forEach((p, i) => { const pChunkLen = (p.hash_size || 1) * 2; const segments = []; if (p.path) { for (let j = 0; j < p.path.length; j += pChunkLen) { segments.push(p.path.substring(j, j + pChunkLen).toUpperCase()); } } const fullRoute = segments.join(' \u2192 '); const commaRoute = segments.join(','); const snr = p.snr !== null && p.snr !== undefined ? `${p.snr.toFixed(1)} dB` : '?'; const hops = segments.length; const entry = document.createElement('div'); entry.className = 'path-entry'; entry.innerHTML = `${fullRoute}SNR: ${snr} | Hops: ${hops}`; entry.title = 'Tap to copy route'; entry.addEventListener('click', (e) => { e.stopPropagation(); navigator.clipboard.writeText(commaRoute).then(() => { const orig = entry.innerHTML; entry.innerHTML = 'Copied!'; setTimeout(() => { entry.innerHTML = orig; }, 1000); }); }); popup.appendChild(entry); }); element.style.position = 'relative'; element.appendChild(popup); // Adjust if popup overflows viewport const rect = popup.getBoundingClientRect(); if (rect.left < 4) { popup.style.right = 'auto'; popup.style.left = '0'; } // Auto-dismiss after configured timeout (unless disabled) or on outside tap const dismiss = () => popup.remove(); const cfg = window.chatSettingsCache || {}; const noAutoclose = !!cfg.path_popup_no_autoclose; const timeoutSec = parseInt(cfg.path_popup_timeout_sec, 10); if (!noAutoclose) { const ms = (isFinite(timeoutSec) && timeoutSec > 0 ? timeoutSec : 8) * 1000; setTimeout(dismiss, ms); } document.addEventListener('click', function handler(e) { if (!element.contains(e.target)) { dismiss(); document.removeEventListener('click', handler); } }); } /** * Load connection status */ async function loadStatus() { try { const response = await fetch('/api/status'); const data = await response.json(); if (data.success) { updateStatus(data.connected ? 'connected' : 'disconnected'); // Cache device capabilities so the message renderer can decide // whether to expose the raw-resend button (firmware ≥1.16 only). window.deviceCaps = { supports_raw_resend: !!data.supports_raw_resend, fw_ver_code: data.fw_ver_code ?? null, }; // loadStatus and loadMessages run in parallel at page init, so // any messages rendered before this point missed the deviceCaps // check and have no raw-resend button. Walk visible own bubbles // and inject the button where it's missing. injectRawResendButtonsForVisibleMessages(); } } catch (error) { console.error('Error loading status:', error); updateStatus('disconnected'); } } /** * Walk the messagesList and inject the raw-resend button on any own message * bubble that's missing it (e.g. rendered before window.deviceCaps was set, * or re-rendered by displayMessages on channel switch). Safe to call any * time — does nothing when the device doesn't support raw resend. */ function injectRawResendButtonsForVisibleMessages() { if (!window.deviceCaps?.supports_raw_resend) return; const container = document.getElementById('messagesList'); if (!container) return; const wrappers = container.querySelectorAll('.message-wrapper.own[data-msg-id]'); for (const wrapper of wrappers) { const msgId = wrapper.dataset.msgId; if (!msgId || msgId.startsWith('_pending_')) continue; const actionsEl = wrapper.querySelector('.message-actions'); if (!actionsEl || actionsEl.querySelector('.btn-raw-resend')) continue; const rawBtn = document.createElement('button'); rawBtn.className = 'btn btn-outline-secondary btn-msg-action btn-raw-resend'; rawBtn.setAttribute('onclick', `resendChannelMessageRaw(${msgId}, this)`); rawBtn.title = 'Resend (rebroadcast same packet so unreached repeaters can pick it up)'; rawBtn.innerHTML = ''; actionsEl.appendChild(rawBtn); } } /** * Copy text to clipboard with visual feedback */ async function copyToClipboard(text, btnElement) { try { await navigator.clipboard.writeText(text); const icon = btnElement.querySelector('i'); const originalClass = icon.className; icon.className = 'bi bi-check'; setTimeout(() => { icon.className = originalClass; }, 1500); } catch (err) { console.error('Failed to copy:', err); } } /** * Load device information */ async function loadDeviceInfo() { const container = document.getElementById('deviceInfoContent'); container.innerHTML = '
Loading...
'; try { const response = await fetch('/api/device/info'); const data = await response.json(); if (!data.success) { container.innerHTML = `
${escapeHtml(data.error)}
`; return; } // API returns info as a dict directly (v2 DeviceManager) const info = data.info; if (!info || typeof info !== 'object') { container.innerHTML = `
No device info available
`; return; } // Type mapping const typeNames = { 1: 'Companion', 2: 'Repeater', 3: 'Room Server', 4: 'Sensor' }; const typeName = typeNames[info.adv_type] || `Unknown (${info.adv_type})`; // Shorten public key for display const pubKey = info.public_key || ''; const shortKey = pubKey.length > 12 ? `${pubKey.slice(0, 6)}...${pubKey.slice(-6)}` : pubKey; // Location const hasLocation = info.adv_lat && info.adv_lon && (info.adv_lat !== 0 || info.adv_lon !== 0); const coords = hasLocation ? `${info.adv_lat.toFixed(6)}, ${info.adv_lon.toFixed(6)}` : 'Not available'; // Build table rows const rows = [ { label: 'Name', value: escapeHtml(info.name || 'Unknown'), copyValue: info.name }, { label: 'Type', value: typeName }, { label: 'Public Key', value: `${escapeHtml(shortKey)}`, copyValue: pubKey }, { label: 'Location', value: coords, showMap: hasLocation, lat: info.adv_lat, lon: info.adv_lon, name: info.name }, { label: 'TX Power', value: `${info.tx_power || 0} / ${info.max_tx_power || 0} dBm` }, { label: 'Frequency', value: `${info.radio_freq || 0} MHz` }, { label: 'Bandwidth', value: `${info.radio_bw || 0} kHz` }, { label: 'Spreading Factor', value: info.radio_sf || 0 }, { label: 'Coding Rate', value: `4/${info.radio_cr || 0}` }, { label: 'Multi Acks', value: info.multi_acks ? 'Enabled' : 'Disabled' }, { label: 'Location Sharing', value: info.adv_loc_policy ? 'Enabled' : 'Disabled' }, { label: 'Manual Add Contacts', value: info.manual_add_contacts ? 'Yes' : 'No' } ]; let html = ''; html += ''; for (const row of rows) { html += ''; html += ``; html += ''; html += ''; } html += '
${row.label}'; html += row.value; // Copy button if (row.copyValue) { html += ` `; } // Map button if (row.showMap) { html += ` `; } html += '
'; container.innerHTML = html; } catch (error) { console.error('Error loading device info:', error); container.innerHTML = '
Failed to load device info
'; } } /** * Load device statistics (Stats tab in Device modal) */ async function loadDeviceStats() { const container = document.getElementById('deviceStatsContent'); if (!container) return; container.innerHTML = '
Loading...
'; try { const response = await fetch('/api/device/stats'); const data = await response.json(); if (!data.success) { container.innerHTML = `
${escapeHtml(data.error)}
`; return; } const stats = data.stats || {}; const bat = data.battery || {}; let html = ''; // Battery (from dedicated get_bat or from core stats) if (bat && typeof bat === 'object' && bat.voltage) { html += ``; } else if (stats.core && stats.core.battery_mv) { html += ``; } // Core stats if (stats.core) { const c = stats.core; if (c.uptime !== undefined) { const d = Math.floor(c.uptime / 86400); const h = Math.floor((c.uptime % 86400) / 3600); const m = Math.floor((c.uptime % 3600) / 60); html += ``; } if (c.queue_length !== undefined) html += ``; if (c.errors !== undefined) html += ``; } // Radio stats if (stats.radio) { const r = stats.radio; if (r.tx_air_time !== undefined) html += ``; if (r.rx_air_time !== undefined) html += ``; } // Packet stats if (stats.packets) { const p = stats.packets; if (p.sent !== undefined) html += ``; if (p.received !== undefined) html += ``; } // DB stats (included in same response) if (data.db_stats) { const db = data.db_stats; if (db.contacts !== undefined) html += ``; if (db.channel_messages !== undefined) html += ``; if (db.direct_messages !== undefined) html += ``; if (db.db_size_bytes !== undefined) { const sizeMB = (db.db_size_bytes / (1024 * 1024)).toFixed(1); html += ``; } } html += '
Battery${bat.voltage}V
Battery${(stats.core.battery_mv / 1000).toFixed(2)}V
Uptime${d}d ${h}h ${m}m
Queue${c.queue_length}
Errors${c.errors}
TX Air Time${r.tx_air_time.toFixed(1)} min
RX Air Time${r.rx_air_time.toFixed(1)} min
Packets TX${p.sent.toLocaleString()}
Packets RX${p.received.toLocaleString()}
Contacts (DB)${db.contacts}
Channel Msgs${db.channel_messages.toLocaleString()}
Direct Msgs${db.direct_messages.toLocaleString()}
DB Size${sizeMB} MB
'; if (html === '
') { container.innerHTML = '
No statistics available
'; } else { container.innerHTML = html; } } catch (error) { console.error('Error loading device stats:', error); container.innerHTML = '
Failed to load stats
'; } } // Load stats when Stats tab is clicked document.addEventListener('DOMContentLoaded', () => { document.getElementById('statsTabBtn')?.addEventListener('shown.bs.tab', loadDeviceStats); document.getElementById('shareTabBtn')?.addEventListener('shown.bs.tab', loadDeviceShare); }); /** * Load device share tab - generate QR code and URI for sharing own contact */ async function loadDeviceShare() { const container = document.getElementById('deviceShareContent'); if (!container) return; container.innerHTML = '
Loading...
'; try { const response = await fetch('/api/device/info'); const data = await response.json(); if (!data.success) { container.innerHTML = `
${escapeHtml(data.error)}
`; return; } const info = data.info; if (!info || !info.public_key || !info.name) { container.innerHTML = '
Device info not available
'; return; } const contactType = info.adv_type || 1; const uri = `meshcore://contact/add?name=${encodeURIComponent(info.name)}&public_key=${info.public_key}&type=${contactType}`; const typeNames = { 1: 'Companion', 2: 'Repeater', 3: 'Room Server', 4: 'Sensor' }; let html = '
'; html += '

Share this QR code or URI so others can add your device as a contact.

'; html += '
'; html += '
' + escapeHtml(info.name) + '
'; html += '
' + escapeHtml(typeNames[contactType] || 'Unknown') + '
'; html += '
'; html += '
'; html += ''; html += '
'; html += ''; html += ''; html += '
'; html += '
'; container.innerHTML = html; // Generate QR code const qrContainer = document.getElementById('shareQrCode'); if (qrContainer && typeof QRCode !== 'undefined') { new QRCode(qrContainer, { text: uri, width: 200, height: 200, colorDark: '#000000', colorLight: '#ffffff', correctLevel: QRCode.CorrectLevel.M }); } } catch (error) { console.error('Error loading device share:', error); container.innerHTML = '
Failed to load device info
'; } } // ============================================================================= // Device Settings (Settings Modal - Device Tab) // ============================================================================= const RADIO_PRESETS = [ { label: 'Australia', freq: 915.800, bw: 250, sf: 10, cr: 5 }, { label: 'Australia (Narrow)', freq: 916.575, bw: 62.5, sf: 7, cr: 8 }, { label: 'Australia: SA, WA', freq: 923.125, bw: 62.5, sf: 8, cr: 8 }, { label: 'Australia: QLD', freq: 923.125, bw: 62.5, sf: 8, cr: 5 }, { label: 'EU/UK (Narrow)', freq: 869.618, bw: 62.5, sf: 8, cr: 8 }, { label: 'EU/UK (Deprecated)', freq: 869.525, bw: 250, sf: 11, cr: 5 }, { label: 'Czech Republic (Narrow)', freq: 869.432, bw: 62.5, sf: 7, cr: 8 }, { label: 'EU 433MHz (Long Range)', freq: 433.650, bw: 250, sf: 11, cr: 5 }, { label: 'New Zealand', freq: 917.375, bw: 250, sf: 11, cr: 5 }, { label: 'New Zealand (Narrow)', freq: 917.375, bw: 62.5, sf: 7, cr: 5 }, { label: 'Portugal 433', freq: 433.375, bw: 62.5, sf: 9, cr: 6 }, { label: 'Portugal 868', freq: 869.618, bw: 62.5, sf: 7, cr: 6 }, { label: 'Switzerland', freq: 869.618, bw: 62.5, sf: 8, cr: 8 }, { label: 'USA/Canada (Recommended)', freq: 910.525, bw: 62.5, sf: 7, cr: 5 }, { label: 'Vietnam (Narrow)', freq: 920.250, bw: 62.5, sf: 8, cr: 5 }, { label: 'Vietnam (Deprecated)', freq: 920.250, bw: 250, sf: 11, cr: 5 }, ]; async function loadDeviceConfig() { try { const resp = await fetch('/api/device/config'); if (!resp.ok) return; const data = await resp.json(); if (!data.success) return; const c = data.config; // Public Info document.getElementById('settDeviceName').value = c.name || ''; document.getElementById('settDeviceLat').value = c.lat || ''; document.getElementById('settDeviceLon').value = c.lon || ''; document.getElementById('settDeviceAdvertLoc').checked = !!c.advert_loc_policy; const phmSel = document.getElementById('settDevicePathHashMode'); if (phmSel) { const phm = (c.path_hash_mode === 0 || c.path_hash_mode === 1 || c.path_hash_mode === 2) ? String(c.path_hash_mode) : '0'; phmSel.value = phm; phmSel.dataset.initial = phm; } // Radio document.getElementById('settRadioFreq').value = c.radio_freq || ''; // Match bandwidth to closest option const bwSelect = document.getElementById('settRadioBw'); if (bwSelect && c.radio_bw) { const bwVal = parseFloat(c.radio_bw); let bestOpt = bwSelect.options[0]; let bestDiff = Infinity; for (const opt of bwSelect.options) { const diff = Math.abs(parseFloat(opt.value) - bwVal); if (diff < bestDiff) { bestDiff = diff; bestOpt = opt; } } bwSelect.value = bestOpt.value; } document.getElementById('settRadioSf').value = c.radio_sf || ''; document.getElementById('settRadioCr').value = c.radio_cr || ''; document.getElementById('settRadioTxPower').value = c.tx_power || ''; // Reset preset dropdown document.getElementById('settRadioPreset').value = ''; } catch (e) { console.error('Failed to load device config:', e); } } async function saveDevicePublicInfo() { const name = document.getElementById('settDeviceName').value.trim(); if (!name) { showNotification('Device name cannot be empty', 'danger'); document.getElementById('settDeviceName').focus(); return; } const lat = parseFloat(document.getElementById('settDeviceLat').value) || 0; const lon = parseFloat(document.getElementById('settDeviceLon').value) || 0; const advertLoc = document.getElementById('settDeviceAdvertLoc').checked; const phmSel = document.getElementById('settDevicePathHashMode'); const payload = { name: name, lat: lat, lon: lon, advert_loc_policy: advertLoc }; if (phmSel && phmSel.value !== phmSel.dataset.initial) { payload.path_hash_mode = parseInt(phmSel.value, 10); } try { const resp = await fetch('/api/device/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const data = await resp.json(); if (data.success) { showNotification('Public info saved', 'success'); _selfInfo = null; if (phmSel) phmSel.dataset.initial = phmSel.value; } else { showNotification(data.error || 'Failed to save', 'danger'); } } catch (e) { showNotification('Failed to save public info', 'danger'); } } async function saveDeviceRadioSettings() { const freq = parseFloat(document.getElementById('settRadioFreq').value); const bw = parseFloat(document.getElementById('settRadioBw').value); const sf = parseInt(document.getElementById('settRadioSf').value, 10); const cr = parseInt(document.getElementById('settRadioCr').value, 10); const txPower = parseInt(document.getElementById('settRadioTxPower').value, 10); if (isNaN(freq) || freq < 100 || freq > 1000) { showNotification('Invalid frequency', 'danger'); return; } if (isNaN(sf) || sf < 5 || sf > 12) { showNotification('Spreading factor must be 5-12', 'danger'); return; } if (isNaN(cr) || cr < 5 || cr > 8) { showNotification('Coding rate must be 5-8', 'danger'); return; } if (isNaN(txPower) || txPower < 0 || txPower > 30) { showNotification('TX power must be 0-30 dBm', 'danger'); return; } if (!confirm('Changing radio settings will disconnect from the mesh network. Continue?')) return; try { const resp = await fetch('/api/device/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ radio_freq: freq, radio_bw: bw, radio_sf: sf, radio_cr: cr, tx_power: txPower }) }); const data = await resp.json(); if (data.success) { showNotification('Radio settings saved', 'success'); } else { showNotification(data.error || 'Failed to save', 'danger'); } } catch (e) { showNotification('Failed to save radio settings', 'danger'); } } function populateRadioPresets() { const select = document.getElementById('settRadioPreset'); if (!select) return; select.innerHTML = ''; RADIO_PRESETS.forEach((preset, idx) => { const opt = document.createElement('option'); opt.value = idx; opt.textContent = `${preset.label} — ${preset.freq} / SF${preset.sf} / BW${preset.bw} / CR${preset.cr}`; select.appendChild(opt); }); } function applyRadioPreset(idx) { const preset = RADIO_PRESETS[idx]; if (!preset) return; document.getElementById('settRadioFreq').value = preset.freq; document.getElementById('settRadioBw').value = preset.bw; document.getElementById('settRadioSf').value = preset.sf; document.getElementById('settRadioCr').value = preset.cr; } // --- Coordinate Map Picker --- let _coordPickerMap = null; let _coordPickerMarker = null; let _coordPickerLatLng = null; function openCoordPicker() { _coordPickerLatLng = null; const modalEl = document.getElementById('coordPickerModal'); if (!modalEl) return; const confirmBtn = document.getElementById('coordPickerConfirmBtn'); const label = document.getElementById('coordPickerLabel'); if (confirmBtn) confirmBtn.disabled = true; if (label) label.textContent = 'Click on the map to select coordinates'; const modal = new bootstrap.Modal(modalEl); const onShown = function () { const backdrops = document.querySelectorAll('.modal-backdrop'); if (backdrops.length > 0) { backdrops[backdrops.length - 1].style.zIndex = '1075'; } if (!_coordPickerMap) { _coordPickerMap = L.map('coordPickerMap').setView([52.0, 19.0], 6); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap' }).addTo(_coordPickerMap); _coordPickerMap.on('click', function (e) { _coordPickerLatLng = e.latlng; if (_coordPickerMarker) { _coordPickerMarker.setLatLng(e.latlng); } else { _coordPickerMarker = L.marker(e.latlng).addTo(_coordPickerMap); } if (label) label.textContent = `${e.latlng.lat.toFixed(6)}, ${e.latlng.lng.toFixed(6)}`; if (confirmBtn) confirmBtn.disabled = false; }); } _coordPickerMap.invalidateSize(); // Center on current lat/lon if set const curLat = parseFloat(document.getElementById('settDeviceLat').value); const curLon = parseFloat(document.getElementById('settDeviceLon').value); if (!isNaN(curLat) && !isNaN(curLon) && (curLat !== 0 || curLon !== 0)) { _coordPickerMap.setView([curLat, curLon], 13); if (_coordPickerMarker) { _coordPickerMarker.setLatLng([curLat, curLon]); } else { _coordPickerMarker = L.marker([curLat, curLon]).addTo(_coordPickerMap); } _coordPickerLatLng = { lat: curLat, lng: curLon }; if (label) label.textContent = `${curLat.toFixed(6)}, ${curLon.toFixed(6)}`; if (confirmBtn) confirmBtn.disabled = false; } else { // Remove old marker if coords are empty if (_coordPickerMarker) { _coordPickerMap.removeLayer(_coordPickerMarker); _coordPickerMarker = null; } _coordPickerMap.setView([52.0, 19.0], 6); } modalEl.removeEventListener('shown.bs.modal', onShown); }; modalEl.addEventListener('shown.bs.modal', onShown); modal.show(); } // ============================================================================= // Settings Modal // ============================================================================= // --- Chat Settings --- const CHAT_SETTINGS_DEFAULTS = { quote_max_bytes: 20, path_popup_timeout_sec: 8, path_popup_no_autoclose: false }; const CHAT_SETTINGS_INT_FIELDS = { quote_max_bytes: 'settQuoteMaxBytes', path_popup_timeout_sec: 'settPathPopupTimeout' }; const CHAT_SETTINGS_BOOL_FIELDS = { path_popup_no_autoclose: 'settPathPopupNoAutoclose' }; let chatSettingsCache = { ...CHAT_SETTINGS_DEFAULTS }; window.chatSettingsCache = chatSettingsCache; function populateChatSettingsForm(data) { for (const [key, elId] of Object.entries(CHAT_SETTINGS_INT_FIELDS)) { const el = document.getElementById(elId); if (el) el.value = data[key] ?? CHAT_SETTINGS_DEFAULTS[key]; } for (const [key, elId] of Object.entries(CHAT_SETTINGS_BOOL_FIELDS)) { const el = document.getElementById(elId); if (el) el.checked = !!(data[key] ?? CHAT_SETTINGS_DEFAULTS[key]); } } async function loadChatSettings() { try { const resp = await fetch('/api/chat/settings'); if (resp.ok) { const data = await resp.json(); chatSettingsCache = { ...CHAT_SETTINGS_DEFAULTS, ...data }; window.chatSettingsCache = chatSettingsCache; populateChatSettingsForm(chatSettingsCache); } } catch (e) { console.error('Failed to load chat settings:', e); } } async function saveChatSettings() { const payload = {}; for (const [key, elId] of Object.entries(CHAT_SETTINGS_INT_FIELDS)) { const el = document.getElementById(elId); const val = parseInt(el.value, 10); if (isNaN(val) || val < parseInt(el.min) || val > parseInt(el.max)) { showNotification(`Invalid value for ${el.previousElementSibling?.textContent || key}`, 'danger'); el.focus(); return; } payload[key] = val; } for (const [key, elId] of Object.entries(CHAT_SETTINGS_BOOL_FIELDS)) { const el = document.getElementById(elId); if (el) payload[key] = !!el.checked; } try { const resp = await fetch('/api/chat/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (resp.ok) { const data = await resp.json(); chatSettingsCache = { ...CHAT_SETTINGS_DEFAULTS, ...data }; window.chatSettingsCache = chatSettingsCache; showNotification('Settings saved', 'success'); } else { const err = await resp.json(); showNotification(err.error || 'Failed to save', 'danger'); } } catch (e) { showNotification('Failed to save settings', 'danger'); } } // --- UI (Interface) Settings --- const UI_SETTINGS_DEFAULTS = { toast_timeout_sec: 2, toast_no_autoclose: false, toast_position: 'top-left' }; const TOAST_POSITION_CLASSES = { 'top-left': ['top-0', 'start-0'], 'top-right': ['top-0', 'end-0'], 'bottom-left': ['bottom-0', 'start-0'], 'bottom-right': ['bottom-0', 'end-0'], 'center': ['top-50', 'start-50', 'translate-middle'] }; const ALL_POSITION_CLASSES = ['top-0', 'top-50', 'start-0', 'start-50', 'bottom-0', 'end-0', 'translate-middle']; let uiSettingsCache = { ...UI_SETTINGS_DEFAULTS }; window.uiSettingsCache = uiSettingsCache; function applyToastPosition(position) { const classes = TOAST_POSITION_CLASSES[position] || TOAST_POSITION_CLASSES['top-left']; document.querySelectorAll('[data-toast-container]').forEach(el => { ALL_POSITION_CLASSES.forEach(c => el.classList.remove(c)); classes.forEach(c => el.classList.add(c)); }); } window.applyToastPosition = applyToastPosition; function populateUiSettingsForm(data) { const t = document.getElementById('settToastTimeout'); if (t) t.value = data.toast_timeout_sec ?? UI_SETTINGS_DEFAULTS.toast_timeout_sec; const noClose = document.getElementById('settToastNoAutoclose'); if (noClose) noClose.checked = !!(data.toast_no_autoclose ?? UI_SETTINGS_DEFAULTS.toast_no_autoclose); const pos = document.getElementById('settToastPosition'); if (pos) pos.value = data.toast_position ?? UI_SETTINGS_DEFAULTS.toast_position; } async function loadUiSettings() { try { const resp = await fetch('/api/ui/settings'); if (resp.ok) { const data = await resp.json(); uiSettingsCache = { ...UI_SETTINGS_DEFAULTS, ...data }; window.uiSettingsCache = uiSettingsCache; applyToastPosition(uiSettingsCache.toast_position); populateUiSettingsForm(uiSettingsCache); } } catch (e) { console.error('Failed to load UI settings:', e); } } async function saveUiSettings() { const timeoutEl = document.getElementById('settToastTimeout'); const timeout = parseFloat(timeoutEl.value); if (isNaN(timeout) || timeout < 1 || timeout > 60) { showNotification('Invalid auto-close duration', 'danger'); timeoutEl.focus(); return; } const payload = { toast_timeout_sec: timeout, toast_no_autoclose: !!document.getElementById('settToastNoAutoclose').checked, toast_position: document.getElementById('settToastPosition').value }; try { const resp = await fetch('/api/ui/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (resp.ok) { const data = await resp.json(); uiSettingsCache = { ...UI_SETTINGS_DEFAULTS, ...data }; window.uiSettingsCache = uiSettingsCache; applyToastPosition(uiSettingsCache.toast_position); showNotification('Settings saved', 'success'); } else { const err = await resp.json(); showNotification(err.error || 'Failed to save', 'danger'); } } catch (e) { showNotification('Failed to save settings', 'danger'); } } // --- DM Retry Settings --- const DM_RETRY_DEFAULTS = { direct_max_retries: 3, direct_flood_retries: 1, flood_max_retries: 3, direct_interval: 30, flood_interval: 60, grace_period: 60 }; const DM_RETRY_FIELDS = { direct_max_retries: 'settDirectMaxRetries', direct_flood_retries: 'settDirectFloodRetries', flood_max_retries: 'settFloodMaxRetries', direct_interval: 'settDirectInterval', flood_interval: 'settFloodInterval', grace_period: 'settGracePeriod' }; function populateDmRetryForm(data) { for (const [key, elId] of Object.entries(DM_RETRY_FIELDS)) { const el = document.getElementById(elId); if (el) el.value = data[key] ?? DM_RETRY_DEFAULTS[key]; } } async function loadDmRetrySettings() { try { const resp = await fetch('/api/dm/auto_retry'); if (resp.ok) { const data = await resp.json(); populateDmRetryForm(data); } } catch (e) { console.error('Failed to load DM retry settings:', e); } } async function saveDmRetrySettings() { const payload = {}; for (const [key, elId] of Object.entries(DM_RETRY_FIELDS)) { const el = document.getElementById(elId); const val = parseInt(el.value, 10); if (isNaN(val) || val < parseInt(el.min) || val > parseInt(el.max)) { showNotification(`Invalid value for ${el.previousElementSibling?.textContent || key}`, 'danger'); el.focus(); return; } payload[key] = val; } try { const resp = await fetch('/api/dm/auto_retry', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (resp.ok) { showNotification('Settings saved', 'success'); } else { const err = await resp.json(); showNotification(err.error || 'Failed to save', 'danger'); } } catch (e) { showNotification('Failed to save settings', 'danger'); } } document.addEventListener('DOMContentLoaded', () => { const settingsModal = document.getElementById('settingsModal'); if (settingsModal) { settingsModal.addEventListener('show.bs.modal', () => { loadDeviceConfig(); loadDmRetrySettings(); loadChatSettings(); loadUiSettings(); loadContactsSettings(); loadRegions(); loadAnalyzers(); }); settingsModal.addEventListener('shown.bs.modal', () => { settingsModal.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => { bootstrap.Tooltip.getOrCreateInstance(el); }); }); } // Contacts tab toggle handlers document.getElementById('settManualApproval')?.addEventListener('change', (e) => { saveContactsSetting('manual_add_contacts', e.target.checked, e.target); }); document.getElementById('settSuppressAdvertNotifs')?.addEventListener('change', (e) => { saveContactsSetting('suppress_advert_notifications', e.target.checked, e.target); }); document.getElementById('settAutoIgnoreAdverts')?.addEventListener('change', (e) => { saveContactsSetting('auto_ignore_new_adverts', e.target.checked, e.target); }); // Initial load so suppress flag is available before user opens Settings loadContactsSettings(); // Regions tab: region registry const addRegionForm = document.getElementById('addRegionForm'); if (addRegionForm) { addRegionForm.addEventListener('submit', (e) => { e.preventDefault(); const input = document.getElementById('newRegionName'); const name = (input?.value || '').trim(); if (!name) return; addRegion(name, input); }); } // Region picker (per-channel): Save button const regionPickerSaveBtn = document.getElementById('regionPickerSaveBtn'); if (regionPickerSaveBtn) { regionPickerSaveBtn.addEventListener('click', () => saveChannelScope()); } // Status-bar region pill: click to open picker for current channel const regionIndicator = document.getElementById('regionIndicator'); if (regionIndicator) { regionIndicator.addEventListener('click', () => openRegionPicker(currentChannelIdx)); } // Analyzer tab: add button + edit form submit const addAnalyzerBtn = document.getElementById('addAnalyzerBtn'); if (addAnalyzerBtn) { addAnalyzerBtn.addEventListener('click', () => openAnalyzerEditModal(null)); } const analyzerEditForm = document.getElementById('analyzerEditForm'); if (analyzerEditForm) { analyzerEditForm.addEventListener('submit', (e) => { e.preventDefault(); saveAnalyzerFromForm(); }); } // Preload analyzers so the first click on a chart icon doesn't need a round-trip. loadAnalyzers(); const dmRetryForm = document.getElementById('dmRetrySettingsForm'); if (dmRetryForm) { dmRetryForm.addEventListener('submit', (e) => { e.preventDefault(); saveDmRetrySettings(); }); } document.getElementById('settingsResetBtn')?.addEventListener('click', () => { populateDmRetryForm(DM_RETRY_DEFAULTS); }); const chatSettingsForm = document.getElementById('chatSettingsForm'); if (chatSettingsForm) { chatSettingsForm.addEventListener('submit', (e) => { e.preventDefault(); saveChatSettings(); }); } document.getElementById('chatSettingsResetBtn')?.addEventListener('click', () => { populateChatSettingsForm(CHAT_SETTINGS_DEFAULTS); }); const uiSettingsForm = document.getElementById('uiSettingsForm'); if (uiSettingsForm) { uiSettingsForm.addEventListener('submit', (e) => { e.preventDefault(); saveUiSettings(); }); } document.getElementById('uiSettingsResetBtn')?.addEventListener('click', () => { populateUiSettingsForm(UI_SETTINGS_DEFAULTS); }); // --- Device Settings --- const devicePublicInfoForm = document.getElementById('devicePublicInfoForm'); if (devicePublicInfoForm) { devicePublicInfoForm.addEventListener('submit', (e) => { e.preventDefault(); saveDevicePublicInfo(); }); } const deviceRadioForm = document.getElementById('deviceRadioForm'); if (deviceRadioForm) { deviceRadioForm.addEventListener('submit', (e) => { e.preventDefault(); saveDeviceRadioSettings(); }); } populateRadioPresets(); document.getElementById('settRadioPreset')?.addEventListener('change', (e) => { const idx = parseInt(e.target.value, 10); if (!isNaN(idx)) applyRadioPreset(idx); }); document.getElementById('settDevicePickMapBtn')?.addEventListener('click', () => { openCoordPicker(); }); document.getElementById('coordPickerConfirmBtn')?.addEventListener('click', () => { if (_coordPickerLatLng) { document.getElementById('settDeviceLat').value = _coordPickerLatLng.lat.toFixed(6); document.getElementById('settDeviceLon').value = _coordPickerLatLng.lng.toFixed(6); } bootstrap.Modal.getInstance(document.getElementById('coordPickerModal'))?.hide(); }); // Load settings caches on startup (for quote dialog, path popup, toast behavior) loadChatSettings(); loadUiSettings(); }); /** * 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; } } } // ============================================================================ // PWA Notifications // ============================================================================ /** * Request notification permission from user * Stores result in localStorage */ async function requestNotificationPermission() { if (!('Notification' in window)) { showNotification('Notifications are not supported in this browser', 'warning'); return false; } try { const permission = await Notification.requestPermission(); if (permission === 'granted') { localStorage.setItem('mc_notifications_enabled', 'true'); updateNotificationToggleUI(); showNotification('Notifications enabled', 'success'); return true; } else if (permission === 'denied') { localStorage.setItem('mc_notifications_enabled', 'false'); updateNotificationToggleUI(); showNotification('Notifications blocked. Change browser settings to enable them.', 'warning'); return false; } } catch (error) { console.error('Error requesting notification permission:', error); showNotification('Error enabling notifications', 'danger'); return false; } } /** * Check current notification permission status */ function getNotificationPermission() { if (!('Notification' in window)) { return 'unsupported'; } return Notification.permission; } /** * Check if notifications are enabled by user */ function areNotificationsEnabled() { return localStorage.getItem('mc_notifications_enabled') === 'true' && getNotificationPermission() === 'granted'; } /** * Update notification toggle button UI */ function updateNotificationToggleUI() { const toggleBtn = document.getElementById('notificationsToggle'); const statusBadge = document.getElementById('notificationStatus'); if (!toggleBtn || !statusBadge) return; const permission = getNotificationPermission(); const isEnabled = localStorage.getItem('mc_notifications_enabled') === 'true'; if (permission === 'unsupported') { statusBadge.className = 'badge bg-secondary'; statusBadge.textContent = 'Unavailable'; toggleBtn.disabled = true; } else if (permission === 'denied') { statusBadge.className = 'badge bg-danger'; statusBadge.textContent = 'Blocked'; toggleBtn.disabled = false; } else if (permission === 'granted' && isEnabled) { statusBadge.className = 'badge bg-success'; statusBadge.textContent = 'Enabled'; toggleBtn.disabled = false; } else { // permission === 'default' OR (permission === 'granted' AND !isEnabled) statusBadge.className = 'badge bg-secondary'; statusBadge.textContent = 'Disabled'; toggleBtn.disabled = false; } } /** * Handle notification toggle button click */ async function handleNotificationToggle() { const permission = getNotificationPermission(); if (permission === 'granted') { // Permission granted - toggle between enabled/disabled const isCurrentlyEnabled = localStorage.getItem('mc_notifications_enabled') === 'true'; if (isCurrentlyEnabled) { // Turn OFF localStorage.setItem('mc_notifications_enabled', 'false'); updateNotificationToggleUI(); showNotification('Notifications disabled', 'info'); } else { // Turn ON localStorage.setItem('mc_notifications_enabled', 'true'); updateNotificationToggleUI(); showNotification('Notifications enabled', 'success'); } } else if (permission === 'denied') { // Blocked - show help message showNotification('Notifications are blocked. Change browser settings: Settings → Site Settings → Notifications', 'warning'); } else { // Not yet requested - ask for permission await requestNotificationPermission(); } } // ============================================================================= // Contacts Settings (Settings modal → Contacts tab) // ============================================================================= window.contactsSettings = { manual_add_contacts: false, suppress_advert_notifications: false, auto_ignore_new_adverts: false, }; async function loadContactsSettings() { try { const resp = await fetch('/api/contacts/settings'); if (!resp.ok) return; const data = await resp.json(); if (!data.success) return; const s = data.settings || {}; window.contactsSettings = { manual_add_contacts: !!s.manual_add_contacts, suppress_advert_notifications: !!s.suppress_advert_notifications, auto_ignore_new_adverts: !!s.auto_ignore_new_adverts, }; const m = document.getElementById('settManualApproval'); const s1 = document.getElementById('settSuppressAdvertNotifs'); const s2 = document.getElementById('settAutoIgnoreAdverts'); if (m) m.checked = window.contactsSettings.manual_add_contacts; if (s1) s1.checked = window.contactsSettings.suppress_advert_notifications; if (s2) s2.checked = window.contactsSettings.auto_ignore_new_adverts; applyContactsSettingsEnableState(window.contactsSettings.manual_add_contacts); // If suppress was just turned on while page open, clear the FAB badge now if (window.contactsSettings.suppress_advert_notifications) { updateFabBadge('.fab-contacts', 'fab-badge-pending', 0); } } catch (e) { console.error('Error loading contacts settings:', e); } } function applyContactsSettingsEnableState(manualOn) { const s1 = document.getElementById('settSuppressAdvertNotifs'); const s2 = document.getElementById('settAutoIgnoreAdverts'); const l1 = document.getElementById('settSuppressAdvertNotifsLabel'); const l2 = document.getElementById('settAutoIgnoreAdvertsLabel'); [s1, s2].forEach(el => { if (!el) return; el.disabled = !manualOn; }); [l1, l2].forEach(el => { if (!el) return; el.classList.toggle('text-muted', !manualOn); }); } async function saveContactsSetting(key, value, inputEl) { try { const resp = await fetch('/api/contacts/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ [key]: value }), }); const data = await resp.json().catch(() => ({})); if (!resp.ok || !data.success) { if (inputEl) inputEl.checked = !value; showNotification(data.error || 'Failed to save setting', 'danger'); return; } window.contactsSettings[key] = !!value; if (key === 'manual_add_contacts') { applyContactsSettingsEnableState(!!value); } if (key === 'suppress_advert_notifications' && value) { updateFabBadge('.fab-contacts', 'fab-badge-pending', 0); } if (key === 'suppress_advert_notifications' && !value) { // Re-fetch real count when re-enabling notifications updatePendingContactsBadge(); } } catch (e) { console.error('Error saving contacts setting:', e); if (inputEl) inputEl.checked = !value; showNotification('Network error saving setting', 'danger'); } } // ================================================================ // Region Registry (Settings > Channels) // ================================================================ // Mirrors the firmware RegionMap::is_name_char rule: '-', '$', '#', // digits, or any byte >= 'A'. UTF-8 bytes >= 0x80 pass via byte >= 'A'. function isValidRegionName(name) { if (!name || typeof name !== 'string') return false; const bytes = new TextEncoder().encode(name); if (bytes.length === 0 || bytes.length > 30) return false; for (const b of bytes) { if (b === 0x2d || b === 0x24 || b === 0x23) continue; // - $ # if (b >= 0x30 && b <= 0x39) continue; // digits if (b >= 0x41) continue; // >= 'A' return false; } return true; } async function loadRegions() { const listEl = document.getElementById('regionsList'); if (!listEl) return; try { const resp = await fetch('/api/regions'); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = await resp.json(); if (!data.success) throw new Error(data.error || 'Failed'); window.regionRegistry = data.regions || []; renderRegionsList(); } catch (e) { console.error('Error loading regions:', e); listEl.innerHTML = '
Failed to load regions
'; } } function renderRegionsList() { const listEl = document.getElementById('regionsList'); if (!listEl) return; const regions = window.regionRegistry || []; if (regions.length === 0) { listEl.innerHTML = '
No regions defined. Add one below.
'; return; } const noDefault = !regions.some(r => r.is_default); const noneRow = `
None — use firmware default
`; const regionRows = regions.map(r => { const isDefault = r.is_default ? 'checked' : ''; const keyShort = (r.key_hex || '').slice(0, 8) + '…'; return `
${escapeHtml(r.name)}
${keyShort}
`; }).join(''); listEl.innerHTML = noneRow + regionRows; } async function addRegion(name, inputEl) { if (!isValidRegionName(name)) { showNotification('Invalid region name. Allowed: letters, digits, - $ # (max 30 bytes, no spaces).', 'warning'); return; } try { const resp = await fetch('/api/regions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }), }); const data = await resp.json().catch(() => ({})); if (!resp.ok || !data.success) { showNotification(data.error || 'Failed to add region', 'danger'); return; } if (inputEl) inputEl.value = ''; await loadRegions(); } catch (e) { console.error('Error adding region:', e); showNotification('Network error adding region', 'danger'); } } async function deleteRegion(id, name) { if (!confirm(`Delete region "${name}"?\nChannels using this region will revert to no scope.`)) return; try { const resp = await fetch(`/api/regions/${id}`, { method: 'DELETE' }); const data = await resp.json().catch(() => ({})); if (!resp.ok || !data.success) { showNotification(data.error || 'Failed to delete region', 'danger'); return; } await loadRegions(); } catch (e) { console.error('Error deleting region:', e); showNotification('Network error deleting region', 'danger'); } } async function setDefaultRegion(id) { try { const resp = await fetch(`/api/regions/${id}/default`, { method: 'POST' }); const data = await resp.json().catch(() => ({})); if (!resp.ok || !data.success) { showNotification(data.error || 'Failed to set default region', 'danger'); await loadRegions(); // snap UI back to server truth return; } if (data.warning) { showNotification(data.warning, 'warning'); } // Update the local cache in place so radio stays checked without flicker. (window.regionRegistry || []).forEach(r => { r.is_default = (r.id === id) ? 1 : 0; }); } catch (e) { console.error('Error setting default region:', e); showNotification('Network error setting default', 'danger'); await loadRegions(); } } async function clearDefaultRegion() { try { const resp = await fetch('/api/regions/default', { method: 'DELETE' }); const data = await resp.json().catch(() => ({})); if (!resp.ok || !data.success) { showNotification(data.error || 'Failed to clear default region', 'danger'); await loadRegions(); // snap UI back to server truth return; } if (data.warning) { showNotification(data.warning, 'warning'); } (window.regionRegistry || []).forEach(r => { r.is_default = 0; }); } catch (e) { console.error('Error clearing default region:', e); showNotification('Network error clearing default', 'danger'); await loadRegions(); } } // ================================================================ // Analyzers (Settings > Analyzer + group chat View in Analyzer button) // ================================================================ const ANALYZER_PLACEHOLDER = '{packetHash}'; window.analyzerCache = window.analyzerCache || { analyzers: [], letsmesh_url_template: 'https://analyzer.letsmesh.net/packets?packet_hash={packetHash}', loaded: false, }; function substituteAnalyzerUrl(template, packetHash) { return (template || '').replaceAll(ANALYZER_PLACEHOLDER, packetHash || ''); } // Bootstrap stacks modal backdrops at z-index 1050 by default, which sits // below an already-open modal (1055). Bump the latest backdrop above the // underlying modal so our stacked dialog actually dims the page. function _bumpAnalyzerBackdrop() { const backdrops = document.querySelectorAll('.modal-backdrop'); if (backdrops.length > 0) { backdrops[backdrops.length - 1].style.zIndex = '1075'; } } async function loadAnalyzers() { try { const resp = await fetch('/api/analyzers'); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = await resp.json(); if (!data.success) throw new Error(data.error || 'Failed'); window.analyzerCache.analyzers = data.analyzers || []; if (data.letsmesh_url_template) { window.analyzerCache.letsmesh_url_template = data.letsmesh_url_template; } window.analyzerCache.loaded = true; renderAnalyzersList(); } catch (e) { console.error('Error loading analyzers:', e); const listEl = document.getElementById('analyzersList'); if (listEl) { listEl.innerHTML = '
Failed to load analyzers
'; } } } function renderAnalyzersList() { const listEl = document.getElementById('analyzersList'); if (!listEl) return; const analyzers = window.analyzerCache.analyzers || []; const builtinRow = `
Letsmesh Analyzer built-in
${escapeHtml(window.analyzerCache.letsmesh_url_template)}
`; if (analyzers.length === 0) { listEl.innerHTML = builtinRow + '
No custom analyzers. Click "Add analyzer" to add one.
'; return; } const rows = analyzers.map(a => { const disabled = !!a.is_disabled; const isDefault = !!a.is_default; const starIcon = isDefault ? 'bi-star-fill text-warning' : 'bi-star'; const disabledBadge = disabled ? 'Disabled' : ''; const nameClass = disabled ? 'text-muted text-decoration-line-through' : ''; const safeName = escapeHtml(a.name); return `
${safeName}${disabledBadge}
${escapeHtml(a.url_template)}
`; }).join(''); listEl.innerHTML = builtinRow + rows; } function openAnalyzerEditModal(id) { const modalEl = document.getElementById('analyzerEditModal'); if (!modalEl) return; const titleEl = document.getElementById('analyzerEditModalTitle'); const idEl = document.getElementById('analyzerEditId'); const nameEl = document.getElementById('analyzerEditName'); const urlEl = document.getElementById('analyzerEditUrl'); const disabledEl = document.getElementById('analyzerEditDisabled'); const errorEl = document.getElementById('analyzerEditError'); errorEl.classList.add('d-none'); errorEl.textContent = ''; if (id) { const a = (window.analyzerCache.analyzers || []).find(x => x.id === id); if (!a) return; titleEl.textContent = 'Edit analyzer'; idEl.value = String(a.id); nameEl.value = a.name || ''; urlEl.value = a.url_template || ''; disabledEl.checked = !!a.is_disabled; } else { titleEl.textContent = 'Add analyzer'; idEl.value = ''; nameEl.value = ''; urlEl.value = ''; disabledEl.checked = false; } modalEl.addEventListener('shown.bs.modal', _bumpAnalyzerBackdrop, { once: true }); bootstrap.Modal.getOrCreateInstance(modalEl).show(); } async function saveAnalyzerFromForm() { const idEl = document.getElementById('analyzerEditId'); const nameEl = document.getElementById('analyzerEditName'); const urlEl = document.getElementById('analyzerEditUrl'); const disabledEl = document.getElementById('analyzerEditDisabled'); const errorEl = document.getElementById('analyzerEditError'); const id = idEl.value ? parseInt(idEl.value, 10) : null; const name = (nameEl.value || '').trim(); const url_template = (urlEl.value || '').trim(); const is_disabled = !!disabledEl.checked; if (!name) { showAnalyzerFormError('Name is required'); return; } if (!url_template.startsWith('http://') && !url_template.startsWith('https://')) { showAnalyzerFormError('URL must start with http:// or https://'); return; } if (!url_template.includes(ANALYZER_PLACEHOLDER)) { showAnalyzerFormError(`URL must contain the ${ANALYZER_PLACEHOLDER} placeholder`); return; } try { const url = id ? `/api/analyzers/${id}` : '/api/analyzers'; const method = id ? 'PUT' : 'POST'; const body = id ? { name, url_template, is_disabled } : { name, url_template }; const resp = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const data = await resp.json().catch(() => ({})); if (!resp.ok || !data.success) { showAnalyzerFormError(data.error || 'Failed to save analyzer'); return; } // If creating a new analyzer with disabled=true, push the flag in a follow-up PUT. if (!id && is_disabled && data.analyzer) { await fetch(`/api/analyzers/${data.analyzer.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ is_disabled: true }), }); } errorEl.classList.add('d-none'); bootstrap.Modal.getInstance(document.getElementById('analyzerEditModal'))?.hide(); await loadAnalyzers(); } catch (e) { console.error('Error saving analyzer:', e); showAnalyzerFormError('Network error saving analyzer'); } } function showAnalyzerFormError(msg) { const errorEl = document.getElementById('analyzerEditError'); if (!errorEl) return; errorEl.textContent = msg; errorEl.classList.remove('d-none'); } async function deleteAnalyzer(id, name) { if (!confirm(`Delete analyzer "${name}"?`)) return; try { const resp = await fetch(`/api/analyzers/${id}`, { method: 'DELETE' }); const data = await resp.json().catch(() => ({})); if (!resp.ok || !data.success) { showNotification(data.error || 'Failed to delete analyzer', 'danger'); return; } await loadAnalyzers(); } catch (e) { console.error('Error deleting analyzer:', e); showNotification('Network error deleting analyzer', 'danger'); } } async function toggleAnalyzerDefault(id, currentlyDefault) { try { const url = currentlyDefault ? '/api/analyzers/default' : `/api/analyzers/${id}/default`; const method = currentlyDefault ? 'DELETE' : 'POST'; const resp = await fetch(url, { method }); const data = await resp.json().catch(() => ({})); if (!resp.ok || !data.success) { showNotification(data.error || 'Failed to update default', 'danger'); await loadAnalyzers(); return; } await loadAnalyzers(); } catch (e) { console.error('Error toggling analyzer default:', e); showNotification('Network error updating default', 'danger'); await loadAnalyzers(); } } async function toggleAnalyzerDisabled(id, disabled) { try { const resp = await fetch(`/api/analyzers/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ is_disabled: !!disabled }), }); const data = await resp.json().catch(() => ({})); if (!resp.ok || !data.success) { showNotification(data.error || 'Failed to update analyzer', 'danger'); await loadAnalyzers(); return; } await loadAnalyzers(); } catch (e) { console.error('Error toggling analyzer disabled:', e); showNotification('Network error updating analyzer', 'danger'); await loadAnalyzers(); } } function getEnabledCustomAnalyzers() { return (window.analyzerCache.analyzers || []).filter(a => !a.is_disabled); } async function ensureAnalyzersLoaded() { if (!window.analyzerCache.loaded) { await loadAnalyzers(); } } async function openMessageAnalyzer(packetHash) { if (!packetHash) return; await ensureAnalyzersLoaded(); const enabled = getEnabledCustomAnalyzers(); const letsmeshTpl = window.analyzerCache.letsmesh_url_template; // No custom analyzers — open Letsmesh directly. if (enabled.length === 0) { window.open(substituteAnalyzerUrl(letsmeshTpl, packetHash), 'meshcore-analyzer'); return; } // Default exists and is enabled — open it directly. const defaultRow = enabled.find(a => a.is_default); if (defaultRow) { window.open(substituteAnalyzerUrl(defaultRow.url_template, packetHash), 'meshcore-analyzer'); return; } // Otherwise — show chooser modal (Letsmesh + enabled customs sorted by name). openAnalyzerChooser(packetHash, enabled); } function openAnalyzerChooser(packetHash, enabled) { const modalEl = document.getElementById('analyzerChooserModal'); const listEl = document.getElementById('analyzerChooserList'); if (!modalEl || !listEl) return; const letsmeshTpl = window.analyzerCache.letsmesh_url_template; const sorted = (enabled || []).slice().sort((a, b) => (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' }) ); const builtinItem = ` `; const customItems = sorted.map(a => ` `).join(''); listEl.innerHTML = builtinItem + customItems; const modal = bootstrap.Modal.getOrCreateInstance(modalEl); listEl.querySelectorAll('button[data-url]').forEach(btn => { btn.addEventListener('click', () => { window.open(btn.getAttribute('data-url'), 'meshcore-analyzer'); modal.hide(); }, { once: true }); }); modalEl.addEventListener('shown.bs.modal', _bumpAnalyzerBackdrop, { once: true }); modal.show(); } // ================================================================ // Per-channel region picker (Manage Channels > row > pin icon) // ================================================================ let _regionPickerChannelIdx = null; let _regionPickerPending = null; // region_id chosen in the radio list, null = "none" async function openRegionPicker(channelIdx) { _regionPickerChannelIdx = channelIdx; // Ensure the registry is loaded (it may not be if user never opened Settings). if (!Array.isArray(window.regionRegistry)) { await loadRegions(); } const ch = (availableChannels || []).find(c => c.index === channelIdx); const nameEl = document.getElementById('regionPickerChannelName'); if (nameEl) nameEl.textContent = ch ? ch.name : `Channel ${channelIdx}`; const currentScope = (window.channelScopes || {})[String(channelIdx)]; _regionPickerPending = currentScope ? currentScope.region_id : null; renderRegionPickerList(); const modalEl = document.getElementById('regionPickerModal'); // Stacked-modal fix: when opened on top of Manage Channels, bump z-index so // the new backdrop dims the channels modal underneath instead of sliding behind it. // Bootstrap reuses the same backdrop element across show/hide cycles, so we must // also reset its inline z-index on hide — otherwise the next non-stacked open // inherits z-index 1065 and the backdrop ends up above the modal, blocking clicks. let bumpedBackdrop = null; const onShown = () => { const backdrops = document.querySelectorAll('.modal-backdrop'); if (backdrops.length > 1) { modalEl.style.zIndex = '1075'; bumpedBackdrop = backdrops[backdrops.length - 1]; bumpedBackdrop.style.zIndex = '1065'; } }; const onHidden = () => { modalEl.style.zIndex = ''; if (bumpedBackdrop) { bumpedBackdrop.style.zIndex = ''; bumpedBackdrop = null; } modalEl.removeEventListener('shown.bs.modal', onShown); modalEl.removeEventListener('hidden.bs.modal', onHidden); }; modalEl.addEventListener('shown.bs.modal', onShown); modalEl.addEventListener('hidden.bs.modal', onHidden); bootstrap.Modal.getOrCreateInstance(modalEl).show(); } function renderRegionPickerList() { const listEl = document.getElementById('regionPickerList'); if (!listEl) return; const regions = window.regionRegistry || []; if (regions.length === 0) { listEl.innerHTML = `

No regions defined yet.

`; document.getElementById('pickerManageRegionsBtn')?.addEventListener('click', () => { bootstrap.Modal.getOrCreateInstance(document.getElementById('regionPickerModal')).hide(); const settingsModal = document.getElementById('settingsModal'); bootstrap.Modal.getOrCreateInstance(settingsModal).show(); // Activate the Channels tab after the modal is shown. settingsModal.addEventListener('shown.bs.modal', function onceShown() { settingsModal.removeEventListener('shown.bs.modal', onceShown); const btn = document.querySelector('[data-bs-target="#tabSettingsRegions"]'); if (btn) bootstrap.Tab.getOrCreateInstance(btn).show(); }); }); return; } const rows = [` `]; for (const r of regions) { rows.push(` `); } listEl.innerHTML = rows.join(''); // Track selection so Save knows what to send. listEl.querySelectorAll('input[name="regionPickerChoice"]').forEach(el => { el.addEventListener('change', (e) => { const v = e.target.value; _regionPickerPending = v === '' ? null : parseInt(v, 10); }); }); } async function loadChannelScopes() { try { const resp = await fetch('/api/channels/scopes'); if (!resp.ok) return; const data = await resp.json(); if (data && data.success) { window.channelScopes = data.scopes || {}; updateRegionIndicator(); } } catch (e) { console.error('Error loading channel scopes:', e); } } function updateRegionIndicator() { const el = document.getElementById('regionIndicator'); const nameEl = document.getElementById('regionIndicatorName'); if (!el || !nameEl) return; const scope = (window.channelScopes || {})[String(currentChannelIdx)]; el.classList.remove('d-none'); if (scope && scope.name) { nameEl.textContent = scope.name; el.classList.remove('bg-light', 'text-secondary', 'border'); el.classList.add('bg-info', 'text-dark'); el.title = 'Click to change region for this channel'; } else { nameEl.textContent = 'No region'; el.classList.remove('bg-info', 'text-dark'); el.classList.add('bg-light', 'text-secondary', 'border'); el.title = 'Click to set a region for this channel'; } } async function saveChannelScope() { if (_regionPickerChannelIdx === null) return; const idx = _regionPickerChannelIdx; const regionId = _regionPickerPending; try { const resp = await fetch(`/api/channels/${idx}/scope`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ region_id: regionId }), }); const data = await resp.json().catch(() => ({})); if (!resp.ok || !data.success) { showNotification(data.error || 'Failed to save region scope', 'danger'); return; } // Update the local cache + re-render the channels list. if (!window.channelScopes) window.channelScopes = {}; if (regionId === null) { delete window.channelScopes[String(idx)]; } else { window.channelScopes[String(idx)] = data.scope; } bootstrap.Modal.getOrCreateInstance(document.getElementById('regionPickerModal')).hide(); if (typeof availableChannels !== 'undefined' && availableChannels.length) { displayChannelsList(availableChannels); } // PR #5 will also refresh the status-bar indicator here. if (typeof updateRegionIndicator === 'function') { updateRegionIndicator(currentChannelIdx); } } catch (e) { console.error('Error saving channel scope:', e); showNotification('Network error saving region scope', 'danger'); } } /** * Send browser notification when new messages arrive * @param {number} channelCount - Number of channels with new messages * @param {number} dmCount - Number of DMs with new messages * @param {number} pendingCount - Number of pending contacts */ function sendBrowserNotification(channelCount, dmCount, pendingCount) { // Only send if enabled and app is hidden if (!areNotificationsEnabled() || document.visibilityState !== 'hidden') { return; } let message = ''; const parts = []; if (channelCount > 0) { parts.push(`${channelCount} ${channelCount === 1 ? 'channel' : 'channels'}`); } if (dmCount > 0) { parts.push(`${dmCount} ${dmCount === 1 ? 'private message' : 'private messages'}`); } if (pendingCount > 0) { parts.push(`${pendingCount} ${pendingCount === 1 ? 'pending contact' : 'pending contacts'}`); } if (parts.length === 0) return; message = `New: ${parts.join(', ')}`; try { const notification = new Notification('mc-webui', { body: message, icon: '/static/images/android-chrome-192x192.png', badge: '/static/images/android-chrome-192x192.png', tag: 'mc-webui-updates', // Prevents spam - replaces previous notification requireInteraction: false, // Auto-dismiss after ~5s silent: false }); // Click handler - bring app to focus notification.onclick = function() { window.focus(); notification.close(); }; } catch (error) { console.error('Error sending notification:', error); } } /** * Track previous counts to detect NEW messages (not just unread) */ let previousTotalUnread = 0; let previousDmUnread = 0; let previousPendingCount = 0; /** * Check if we should send notification based on count changes */ function checkAndNotify() { // Calculate current totals (exclude muted channels) let currentTotalUnread = 0; for (const [idx, count] of Object.entries(unreadCounts)) { if (!mutedChannels.has(parseInt(idx))) { currentTotalUnread += count; } } // Get DM unread count from badge const dmBadge = document.querySelector('.fab-badge-dm'); const currentDmUnread = dmBadge ? parseInt(dmBadge.textContent) || 0 : 0; // Get pending contacts count from badge (forced to 0 when notifications are suppressed) const pendingBadge = document.querySelector('.fab-badge-pending'); const rawPendingCount = pendingBadge ? parseInt(pendingBadge.textContent) || 0 : 0; const currentPendingCount = window.contactsSettings?.suppress_advert_notifications ? 0 : rawPendingCount; // Detect increases (new messages/contacts) const channelIncrease = currentTotalUnread > previousTotalUnread; const dmIncrease = currentDmUnread > previousDmUnread; const pendingIncrease = currentPendingCount > previousPendingCount; // Send notification if ANY category increased if (channelIncrease || dmIncrease || pendingIncrease) { const channelDelta = channelIncrease ? (currentTotalUnread - previousTotalUnread) : 0; const dmDelta = dmIncrease ? (currentDmUnread - previousDmUnread) : 0; const pendingDelta = pendingIncrease ? (currentPendingCount - previousPendingCount) : 0; sendBrowserNotification(channelDelta, dmDelta, pendingDelta); } // Update previous counts previousTotalUnread = currentTotalUnread; previousDmUnread = currentDmUnread; previousPendingCount = currentPendingCount; } /** * Update app icon badge (Android/Desktop) * Shows total unread count across channels + DMs + pending */ function updateAppBadge() { if (!('setAppBadge' in navigator)) { // Badge API not supported return; } // Calculate total unread (exclude muted channels) let channelUnread = 0; for (const [idx, count] of Object.entries(unreadCounts)) { if (!mutedChannels.has(parseInt(idx))) { channelUnread += count; } } const dmBadge = document.querySelector('.fab-badge-dm'); const dmUnread = dmBadge ? parseInt(dmBadge.textContent) || 0 : 0; const pendingBadge = document.querySelector('.fab-badge-pending'); const pendingUnread = pendingBadge ? parseInt(pendingBadge.textContent) || 0 : 0; const totalUnread = channelUnread + dmUnread + pendingUnread; if (totalUnread > 0) { navigator.setAppBadge(totalUnread).catch((error) => { console.error('Error setting app badge:', error); }); } else { navigator.clearAppBadge().catch((error) => { console.error('Error clearing app badge:', error); }); } } /** * 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 cfg = window.uiSettingsCache || {}; const noAutoclose = !!cfg.toast_no_autoclose; const timeoutSec = parseFloat(cfg.toast_timeout_sec); const delay = isFinite(timeoutSec) && timeoutSec > 0 ? Math.round(timeoutSec * 1000) : 2000; const toast = new bootstrap.Toast(toastEl, { autohide: !noAutoclose, delay: delay }); toast.show(); } /** * Check for app updates from GitHub */ async function checkForAppUpdates() { const btn = document.getElementById('checkUpdateBtn'); const icon = document.getElementById('checkUpdateIcon'); const versionText = document.getElementById('versionText'); if (!btn || !icon) return; // Show loading state btn.disabled = true; icon.className = 'bi bi-arrow-repeat spin'; try { const response = await fetch('/api/check-update'); const data = await response.json(); if (data.success) { if (data.update_available) { // Check if remote update is available const updaterStatus = await fetch('/api/updater/status').then(r => r.json()).catch(() => ({ available: false })); const updateLinkContainer = document.getElementById('updateLinkContainer'); const newVersion = `${data.latest_date}+${data.latest_commit}`; const githubUrl = data.github_url; if (updaterStatus.available) { // Show "Update Now" link below version if (updateLinkContainer) { updateLinkContainer.innerHTML = ` Update now`; updateLinkContainer.classList.remove('d-none'); } } else { // Show link to GitHub (no remote update available) if (updateLinkContainer) { updateLinkContainer.innerHTML = ` Update available`; updateLinkContainer.classList.remove('d-none'); } } icon.className = 'bi bi-check-circle-fill text-success'; showNotification(`Update available: ${data.latest_date}+${data.latest_commit}`, 'success'); } else { // Up to date icon.className = 'bi bi-check-circle text-success'; showNotification('You are running the latest version', 'success'); // Reset icon after 3 seconds setTimeout(() => { icon.className = 'bi bi-arrow-repeat'; }, 3000); } } else { // Error icon.className = 'bi bi-exclamation-triangle text-warning'; showNotification(data.error || 'Failed to check for updates', 'warning'); setTimeout(() => { icon.className = 'bi bi-arrow-repeat'; }, 3000); } } catch (error) { console.error('Error checking for updates:', error); icon.className = 'bi bi-exclamation-triangle text-danger'; showNotification('Network error checking for updates', 'danger'); setTimeout(() => { icon.className = 'bi bi-arrow-repeat'; }, 3000); } finally { btn.disabled = false; } } // Store update info for modal let pendingUpdateVersion = null; /** * Open update modal and prepare for remote update */ function openUpdateModal(newVersion, githubUrl) { pendingUpdateVersion = newVersion; // Close offcanvas menu const offcanvas = bootstrap.Offcanvas.getInstance(document.getElementById('mainMenu')); if (offcanvas) offcanvas.hide(); // Reset modal state document.getElementById('updateStatus').classList.remove('d-none'); document.getElementById('updateProgress').classList.add('d-none'); document.getElementById('updateResult').classList.add('d-none'); document.getElementById('updateCancelBtn').classList.remove('d-none'); document.getElementById('updateConfirmBtn').classList.remove('d-none'); document.getElementById('updateReloadBtn').classList.add('d-none'); document.getElementById('updateMessage').textContent = `New version available: ${newVersion}`; // Set up "What's new" link const whatsNewEl = document.getElementById('updateWhatsNew'); if (whatsNewEl && githubUrl) { const link = whatsNewEl.querySelector('a'); if (link) link.href = githubUrl; whatsNewEl.classList.remove('d-none'); } // Hide spinner, show message document.querySelector('#updateStatus .spinner-border').classList.add('d-none'); // Setup confirm button document.getElementById('updateConfirmBtn').onclick = performRemoteUpdate; // Show modal const modal = new bootstrap.Modal(document.getElementById('updateModal')); modal.show(); } /** * Perform remote update via webhook */ async function performRemoteUpdate() { const currentVersion = document.getElementById('versionText')?.textContent?.split(' ')[0] || ''; // Show progress state document.getElementById('updateStatus').classList.add('d-none'); document.getElementById('updateProgress').classList.remove('d-none'); document.getElementById('updateCancelBtn').classList.add('d-none'); document.getElementById('updateConfirmBtn').classList.add('d-none'); document.getElementById('updateProgressMessage').textContent = 'Starting update...'; try { // Trigger update const response = await fetch('/api/updater/trigger', { method: 'POST' }); const data = await response.json(); if (!data.success) { showUpdateResult(false, data.error || 'Failed to start update'); return; } document.getElementById('updateProgressMessage').textContent = 'Update started. Waiting for server to restart...'; // Poll for server to come back up with new version let attempts = 0; const maxAttempts = 60; // 2 minutes max const pollInterval = 2000; // 2 seconds const pollForCompletion = async () => { attempts++; try { const versionResponse = await fetch('/api/version', { cache: 'no-store', headers: { 'Cache-Control': 'no-cache' } }); if (versionResponse.ok) { const versionData = await versionResponse.json(); const newVersion = versionData.version; // Check if version changed if (newVersion !== currentVersion) { showUpdateResult(true, `Updated to ${newVersion}`); return; } } } catch (e) { // Server not responding yet - this is expected during restart document.getElementById('updateProgressMessage').textContent = `Rebuilding containers... (${attempts}/${maxAttempts})`; } if (attempts < maxAttempts) { setTimeout(pollForCompletion, pollInterval); } else { showUpdateResult(false, 'Update timed out. Please check server manually.'); } }; // Start polling after a short delay setTimeout(pollForCompletion, 3000); } catch (error) { console.error('Update error:', error); showUpdateResult(false, 'Network error during update'); } } /** * Show update result in modal */ function showUpdateResult(success, message) { document.getElementById('updateProgress').classList.add('d-none'); document.getElementById('updateResult').classList.remove('d-none'); const icon = document.getElementById('updateResultIcon'); const msg = document.getElementById('updateResultMessage'); if (success) { icon.className = 'bi bi-check-circle-fill text-success fs-1 mb-3 d-block'; msg.className = 'mb-0 text-success'; document.getElementById('updateReloadBtn').classList.remove('d-none'); } else { icon.className = 'bi bi-x-circle-fill text-danger fs-1 mb-3 d-block'; msg.className = 'mb-0 text-danger'; document.getElementById('updateCancelBtn').classList.remove('d-none'); document.getElementById('updateCancelBtn').textContent = 'Close'; } msg.textContent = message; } // Make openUpdateModal globally accessible window.openUpdateModal = openUpdateModal; /** * 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, compare calendar dates const now = new Date(); const yesterday = new Date(now); yesterday.setDate(yesterday.getDate() - 1); if (date.toDateString() === now.toDateString()) { // Today - show time only return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } else if (date.toDateString() === yesterday.toDateString()) { // 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' }); } } /** * Format a unix timestamp as relative time (e.g., "5 min ago", "2h ago") */ function formatTimeAgo(timestamp) { const now = Math.floor(Date.now() / 1000); const diff = now - timestamp; if (diff < 60) return 'just now'; if (diff < 3600) return `${Math.floor(diff / 60)} min ago`; if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`; return new Date(timestamp * 1000).toLocaleDateString(); } /** * 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 = 135; 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; } /** * Format a Unix timestamp for the channel list: HH:MM if today, DD.MM otherwise. */ function formatChannelTime(unixTimestamp) { if (!unixTimestamp) return ''; const d = new Date(unixTimestamp * 1000); const now = new Date(); const isToday = d.toDateString() === now.toDateString(); if (isToday) { return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }); } const day = String(d.getDate()).padStart(2, '0'); const month = String(d.getMonth() + 1).padStart(2, '0'); return `${day}.${month}.${d.getFullYear()}`; } /** * Mirror of backend _make_preview: strip @[name] mention syntax, truncate to 60 chars with ellipsis. */ function makeChannelPreview(text, maxLen = 60) { if (!text) return ''; const stripped = text.replace(/@\[([^\]]+)\]/g, '$1'); if (stripped.length > maxLen) return stripped.slice(0, maxLen) + '…'; return stripped; } // ============================================================================= // Avatar Generation Functions // ============================================================================= /** * Generate a consistent color based on string hash * @param {string} str - Input string (username) * @returns {string} HSL color string */ function getAvatarColor(str) { let hash = 0; for (let i = 0; i < str.length; i++) { hash = str.charCodeAt(i) + ((hash << 5) - hash); } // Generate hue from hash (0-360), keep saturation and lightness fixed for readability const hue = Math.abs(hash) % 360; return `hsl(${hue}, 65%, 45%)`; } /** * Extract first emoji from a string * @param {string} str - Input string * @returns {string|null} First emoji found or null */ function extractFirstEmoji(str) { // Regex to match emojis (including compound emojis with ZWJ sequences) const emojiRegex = /(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)(\u200D(\p{Emoji_Presentation}|\p{Emoji}\uFE0F))*/u; const match = str.match(emojiRegex); return match ? match[0] : null; } /** * Get initials from a username * @param {string} name - Username * @returns {string} 1-2 character initials */ function getInitials(name) { // Remove emojis first const cleanName = name.replace(/(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)(\u200D(\p{Emoji_Presentation}|\p{Emoji}\uFE0F))*/gu, '').trim(); if (!cleanName) return '?'; // Split by common separators (space, underscore, dash) const parts = cleanName.split(/[\s_\-]+/).filter(p => p.length > 0); if (parts.length >= 2) { // Two or more words: use first letter of first two words return (parts[0][0] + parts[1][0]).toUpperCase(); } else if (parts.length === 1) { // Single word: use first letter only return parts[0][0].toUpperCase(); } return '?'; } /** * Generate avatar HTML for a username * @param {string} name - Username * @returns {object} { content: string, color: string } */ function generateAvatar(name) { const emoji = extractFirstEmoji(name); const color = getAvatarColor(name); if (emoji) { return { content: emoji, color: color, isEmoji: true }; } else { return { content: getInitials(name), color: color, isEmoji: false }; } } /** * 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; } // Load muted channels if (data.muted_channels) { mutedChannels = new Set(data.muted_channels); } // Load favorite channels if (data.favorite_channels) { favoriteChannels = new Set(data.favorite_channels); } console.log('Loaded channel read status from server:', lastSeenTimestamps, 'muted:', [...mutedChannels], 'favorites:', [...favoriteChannels]); } 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(); } /** * Mark all channels as read (bell icon click) */ async function markAllChannelsRead() { // Build list of channels with unread messages const unreadChannels = []; for (const [idx, count] of Object.entries(unreadCounts)) { if (count > 0) { const channel = availableChannels.find(ch => ch.index === parseInt(idx)); const name = channel ? channel.name : `Channel ${idx}`; unreadChannels.push({ idx, count, name }); } } if (unreadChannels.length === 0) return; // Show confirmation dialog with list of unread channels const channelList = unreadChannels.map(ch => ` - ${ch.name} (${ch.count})`).join('\n'); if (!confirm(`Mark all messages as read?\n\nUnread channels:\n${channelList}`)) return; // Collect latest timestamps const now = Math.floor(Date.now() / 1000); const timestamps = {}; for (const { idx } of unreadChannels) { timestamps[idx] = now; lastSeenTimestamps[parseInt(idx)] = now; unreadCounts[idx] = 0; } // Update UI immediately updateUnreadBadges(); // Save to server try { await fetch('/api/read_status/mark_all_read', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ channels: timestamps }) }); } catch (error) { console.error('Error marking all as read:', error); } } /** * 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 and last-message preview/time data.channels.forEach(channel => { unreadCounts[channel.index] = channel.unread_count; if (channel.last_message_preview !== undefined) { channelLastMessages[channel.index] = { preview: channel.last_message_preview, timestamp: channel.last_message_time }; } }); // Sync muted channels from server if (data.muted_channels) { mutedChannels = new Set(data.muted_channels); } // Sync favorite channels from server if (data.favorite_channels) { favoriteChannels = new Set(data.favorite_channels); } // Update UI badges updateUnreadBadges(); // Re-render channel lists now that we have last-message timestamps // (initial paint runs before checkForUpdates returns, so the first // sort would otherwise be all-zeros). populateChannelSelector(availableChannels); // Check if we should send browser notification checkAndNotify(); } } 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 notification bell (exclude muted channels) let totalUnread = 0; for (const [idx, count] of Object.entries(unreadCounts)) { if (!mutedChannels.has(parseInt(idx))) { totalUnread += count; } } updateNotificationBell(totalUnread); // Update app icon badge updateAppBadge(); // Update channel sidebar badges (lg+ screens) updateChannelSidebarBadges(); } /** * 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'; } } } /** * Update FAB button badge (universal function for all FAB badges) * @param {string} fabSelector - CSS selector for FAB button (e.g., '.fab-dm', '.fab-contacts') * @param {string} badgeClass - Badge class name (e.g., 'fab-badge-dm', 'fab-badge-pending') * @param {number} count - Number to display (0 = hide badge) */ function updateFabBadge(fabSelector, badgeClass, count) { const fabButton = document.querySelector(fabSelector); if (!fabButton) return; let badge = fabButton.querySelector(`.${badgeClass}`); if (count > 0) { // Show badge if (!badge) { badge = document.createElement('span'); badge.className = `fab-badge ${badgeClass}`; fabButton.appendChild(badge); } badge.textContent = count > 99 ? '99+' : count; badge.style.display = 'inline-block'; } 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'); // Use local emoji data instead of CDN picker.dataSource = '/static/vendor/emoji-picker-element-data/en/emojibase/data.json'; 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); // NOTE: checkForUpdates() is now called separately after messages are displayed // to avoid blocking the initial page load } 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 selector data even if API fails */ function ensurePublicChannel() { const items = window._channelDropdownItems; if (!items || items.length === 0) { console.log('[ensurePublicChannel] Adding fallback Public channel'); availableChannels = [{index: 0, name: 'Public', key: ''}]; populateChannelSelector(availableChannels); } } /** * Sort a channel array by (favorite-first, latest-message-desc, original-index). * Returns a new array; does not mutate the input. Channels with no recorded * last message fall to the bottom of their tier in original order (stable sort). */ function sortedChannelsByFavoriteAndActivity(channels) { return channels .map((ch, i) => ({ ch, i, fav: favoriteChannels.has(ch.index) ? 0 : 1, ts: (channelLastMessages[ch.index] && channelLastMessages[ch.index].timestamp) || 0 })) .sort((a, b) => (a.fav - b.fav) || (b.ts - a.ts) || (a.i - b.i)) .map(x => x.ch); } /** * Populate channel selector data (for both mobile dropdown and wide-screen sidebar) */ function populateChannelSelector(channels) { // Validate input if (!channels || !Array.isArray(channels) || channels.length === 0) { console.warn('[populateChannelSelector] Invalid channels array, using fallback'); channels = [{index: 0, name: 'Public', key: ''}]; } // If the saved channel doesn't exist in the list, fall back to Public (0) if (!channels.some(c => c && c.index === currentChannelIdx)) { console.log(`[populateChannelSelector] Channel ${currentChannelIdx} not found, falling back to Public`); currentChannelIdx = 0; localStorage.setItem('mc_active_channel', '0'); } // Save data for the mobile dropdown (sorted by favorite + latest activity) window._channelDropdownItems = sortedChannelsByFavoriteAndActivity(channels); // Pre-render dropdown contents (still hidden) and update input display renderChannelDropdownItems(''); updateChannelInputDisplay(); console.log(`[populateChannelSelector] Loaded ${channels.length} channels, active: ${currentChannelIdx}`); // Also populate sidebar (lg+ screens) populateChannelSidebar(); } /** * Render channel items into the mobile dropdown, optionally filtered by query. */ function renderChannelDropdownItems(query) { const dropdown = document.getElementById('channelSelectorDropdown'); if (!dropdown) return; dropdown.innerHTML = ''; const channels = window._channelDropdownItems || []; const q = (query || '').toLowerCase().trim(); const filtered = q ? channels.filter(c => c && c.name && c.name.toLowerCase().includes(q)) : channels; if (filtered.length === 0) { const empty = document.createElement('div'); empty.className = 'channel-selector-item text-muted'; empty.style.cursor = 'default'; empty.textContent = q ? 'No matches' : 'No channels'; dropdown.appendChild(empty); return; } filtered.forEach(channel => { if (!channel || typeof channel.index === 'undefined' || !channel.name) return; const item = document.createElement('div'); item.className = 'channel-selector-item'; item.dataset.channelIdx = channel.index; if (channel.index === currentChannelIdx) { item.classList.add('active'); } if (mutedChannels.has(channel.index)) { item.classList.add('muted'); } if (favoriteChannels.has(channel.index)) { item.classList.add('is-favorite'); } // Top row: name + time + unread badge const topRow = document.createElement('div'); topRow.className = 'channel-item-top'; const nameSpan = document.createElement('span'); nameSpan.className = 'channel-name'; nameSpan.textContent = channel.name; topRow.appendChild(nameSpan); const lastMsg = channelLastMessages[channel.index]; if (lastMsg && lastMsg.timestamp) { const timeSpan = document.createElement('span'); timeSpan.className = 'channel-last-time'; timeSpan.textContent = formatChannelTime(lastMsg.timestamp); topRow.appendChild(timeSpan); } const unread = unreadCounts[channel.index] || 0; if (unread > 0 && channel.index !== currentChannelIdx && !mutedChannels.has(channel.index)) { const badge = document.createElement('span'); badge.className = 'sidebar-unread-badge'; badge.textContent = unread; topRow.appendChild(badge); } item.appendChild(topRow); // Preview row (CSS clamps to 1 line for .channel-selector-item) if (lastMsg && lastMsg.preview) { const preview = document.createElement('div'); preview.className = 'channel-item-preview'; preview.textContent = lastMsg.preview; item.appendChild(preview); } item.addEventListener('click', () => { selectChannelFromDropdown(channel.index, channel.name); }); dropdown.appendChild(item); }); } /** * Switch to a channel via the mobile dropdown (closes dropdown, syncs state). */ function selectChannelFromDropdown(idx, name) { currentChannelIdx = idx; localStorage.setItem('mc_active_channel', currentChannelIdx); const input = document.getElementById('channelSelectorInput'); const dropdown = document.getElementById('channelSelectorDropdown'); if (input) { input.value = name; input.blur(); } if (dropdown) dropdown.style.display = 'none'; loadMessages(); updateChannelSidebarActive(); showNotification(`Switched to channel: ${name}`, 'info'); } /** * Sync mobile selector input value with the currently active channel name. */ function updateChannelInputDisplay() { const input = document.getElementById('channelSelectorInput'); if (!input) return; const channels = window._channelDropdownItems || []; const current = channels.find(c => c && c.index === currentChannelIdx); input.value = current ? current.name : 'Public'; } /** * Load channels list in management modal */ async function loadChannelsList() { const listEl = document.getElementById('channelsList'); listEl.innerHTML = '
Loading...
'; try { const [chResp, scResp] = await Promise.all([ fetch('/api/channels'), fetch('/api/channels/scopes'), ]); const data = await chResp.json(); const scData = await scResp.json().catch(() => ({})); window.channelScopes = (scData && scData.success) ? (scData.scopes || {}) : {}; if (data.success) { displayChannelsList(data.channels); } else { listEl.innerHTML = '
Error loading channels
'; } } catch (error) { listEl.innerHTML = '
Failed to load channels
'; } } /** * Display channels in management modal */ function displayChannelsList(channels) { const listEl = document.getElementById('channelsList'); if (channels.length === 0) { listEl.innerHTML = '
No channels configured
'; return; } listEl.innerHTML = ''; channels.forEach(channel => { const item = document.createElement('div'); item.className = 'list-group-item d-flex justify-content-between align-items-center'; const isPublic = channel.index === 0; const isMuted = mutedChannels.has(channel.index); const isFavorite = favoriteChannels.has(channel.index); const scope = (window.channelScopes || {})[String(channel.index)]; const hasScope = !!scope; const scopeTitle = hasScope ? `Region: ${scope.name} — click to change` : 'Set region scope'; item.innerHTML = `
${escapeHtml(channel.name)} ${hasScope ? ` ${escapeHtml(scope.name)}` : ''}
${!isPublic ? ` ` : ''}
`; listEl.appendChild(item); }); } /** * Populate channel sidebar (visible on lg+ screens) */ function populateChannelSidebar() { const list = document.getElementById('channelSidebarList'); if (!list) return; list.innerHTML = ''; const baseChannels = availableChannels.length > 0 ? availableChannels : [{index: 0, name: 'Public', key: ''}]; const channels = sortedChannelsByFavoriteAndActivity(baseChannels); channels.forEach(channel => { if (!channel || typeof channel.index === 'undefined' || !channel.name) return; const item = document.createElement('div'); item.className = 'channel-sidebar-item'; item.dataset.channelIdx = channel.index; if (channel.index === currentChannelIdx) { item.classList.add('active'); } if (mutedChannels.has(channel.index)) { item.classList.add('muted'); } if (favoriteChannels.has(channel.index)) { item.classList.add('is-favorite'); } // Top row: name + time + unread badge const topRow = document.createElement('div'); topRow.className = 'channel-item-top'; const nameSpan = document.createElement('span'); nameSpan.className = 'channel-name'; nameSpan.textContent = channel.name; topRow.appendChild(nameSpan); const lastMsg = channelLastMessages[channel.index]; if (lastMsg && lastMsg.timestamp) { const timeSpan = document.createElement('span'); timeSpan.className = 'channel-last-time'; timeSpan.textContent = formatChannelTime(lastMsg.timestamp); topRow.appendChild(timeSpan); } const unread = unreadCounts[channel.index] || 0; if (unread > 0 && channel.index !== currentChannelIdx && !mutedChannels.has(channel.index)) { const badge = document.createElement('span'); badge.className = 'sidebar-unread-badge'; badge.textContent = unread; topRow.appendChild(badge); } item.appendChild(topRow); // Preview row (only if non-empty preview exists) if (lastMsg && lastMsg.preview) { const preview = document.createElement('div'); preview.className = 'channel-item-preview'; preview.textContent = lastMsg.preview; item.appendChild(preview); } item.addEventListener('click', () => { currentChannelIdx = channel.index; localStorage.setItem('mc_active_channel', currentChannelIdx); loadMessages(); updateChannelSidebarActive(); }); list.appendChild(item); }); } /** * Update active state on channel sidebar items and sync mobile selector input. */ function updateChannelSidebarActive() { const list = document.getElementById('channelSidebarList'); if (list) { list.querySelectorAll('.channel-sidebar-item').forEach(item => { const idx = parseInt(item.dataset.channelIdx); item.classList.toggle('active', idx === currentChannelIdx); }); } // Sync mobile selector input with current channel name updateChannelInputDisplay(); } /** * Move a channel's
  • /
    to the top of its tier (favorite or non-favorite) * in both the sidebar and the mobile dropdown. Pure DOM move — does not rebuild * the list, so the active highlight, scroll, and event listeners survive. */ function moveChannelToTopOfTier(channelIdx) { const isFav = favoriteChannels.has(channelIdx); const idxStr = String(channelIdx); const moveIn = (containerId, childClass) => { const container = document.getElementById(containerId); if (!container) return; const item = container.querySelector(`.${childClass}[data-channel-idx="${idxStr}"]`); if (!item) return; // channel not currently rendered (e.g. dropdown filtered) if (isFav) { if (item !== container.firstElementChild) container.prepend(item); return; } // Non-favorite: insert before the first non-favorite sibling. const firstNonFav = container.querySelector(`:scope > .${childClass}:not(.is-favorite)`); if (!firstNonFav) { // No non-favorite tier yet — append after the favorites block. container.appendChild(item); } else if (firstNonFav !== item) { container.insertBefore(item, firstNonFav); } }; moveIn('channelSidebarList', 'channel-sidebar-item'); moveIn('channelSelectorDropdown', 'channel-selector-item'); } /** * Update unread badges on channel sidebar */ function updateChannelSidebarBadges() { const list = document.getElementById('channelSidebarList'); if (!list) return; list.querySelectorAll('.channel-sidebar-item').forEach(item => { const idx = parseInt(item.dataset.channelIdx); const unread = unreadCounts[idx] || 0; const isMuted = mutedChannels.has(idx); const lastMsg = channelLastMessages[idx]; // Update muted state item.classList.toggle('muted', isMuted); const topRow = item.querySelector('.channel-item-top'); if (!topRow) return; // Update or remove time label (insert before badge if present, else append) let timeEl = topRow.querySelector('.channel-last-time'); if (lastMsg && lastMsg.timestamp) { if (!timeEl) { timeEl = document.createElement('span'); timeEl.className = 'channel-last-time'; const badgeEl = topRow.querySelector('.sidebar-unread-badge'); topRow.insertBefore(timeEl, badgeEl); // insertBefore(x, null) == appendChild } timeEl.textContent = formatChannelTime(lastMsg.timestamp); } else if (timeEl) { timeEl.remove(); } // Update or remove badge let badge = topRow.querySelector('.sidebar-unread-badge'); if (unread > 0 && idx !== currentChannelIdx && !isMuted) { if (!badge) { badge = document.createElement('span'); badge.className = 'sidebar-unread-badge'; topRow.appendChild(badge); } badge.textContent = unread; } else if (badge) { badge.remove(); } // Update or remove preview row let preview = item.querySelector('.channel-item-preview'); if (lastMsg && lastMsg.preview) { if (!preview) { preview = document.createElement('div'); preview.className = 'channel-item-preview'; item.appendChild(preview); } preview.textContent = lastMsg.preview; } else if (preview) { preview.remove(); } }); // Re-render mobile dropdown if currently visible (badges/muted state) const dropdown = document.getElementById('channelSelectorDropdown'); const input = document.getElementById('channelSelectorInput'); if (dropdown && dropdown.style.display !== 'none') { renderChannelDropdownItems(input ? input.value : ''); } } /** * Toggle mute state for a channel */ async function toggleChannelMute(index) { const newMuted = !mutedChannels.has(index); try { const response = await fetch(`/api/channels/${index}/mute`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ muted: newMuted }) }); const data = await response.json(); if (data.success) { if (newMuted) { mutedChannels.add(index); } else { mutedChannels.delete(index); } // Refresh modal list and badges loadChannelsList(); updateUnreadBadges(); } else { showNotification('Failed to update mute state', 'danger'); } } catch (error) { showNotification('Failed to update mute state', 'danger'); } } /** * Toggle favorite state for a channel */ async function toggleChannelFavorite(index) { const newFavorite = !favoriteChannels.has(index); try { const response = await fetch(`/api/channels/${index}/favorite`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ favorite: newFavorite }) }); const data = await response.json(); if (data.success) { if (newFavorite) { favoriteChannels.add(index); } else { favoriteChannels.delete(index); } // Refresh modal (star icon) and rebuild sidebar/dropdown to apply new tier loadChannelsList(); populateChannelSelector(availableChannels); } else { showNotification('Failed to update favorite state', 'danger'); } } catch (error) { showNotification('Failed to update favorite state', 'danger'); } } /** * Delete channel */ async function deleteChannel(index) { const channel = availableChannels.find(ch => ch.index === index); if (!channel) return; if (!confirm(`Remove channel "${channel.name}"?`)) { return; } try { const response = await fetch(`/api/channels/${index}`, { method: 'DELETE' }); const data = await response.json(); if (data.success) { showNotification(`Channel "${channel.name}" removed`, 'success'); // If deleted current channel, switch to Public if (currentChannelIdx === index) { currentChannelIdx = 0; localStorage.setItem('mc_active_channel', '0'); loadMessages(); } // Reload channels await loadChannels(); loadChannelsList(); } else { showNotification('Failed to remove channel: ' + data.error, 'danger'); } } catch (error) { showNotification('Failed to remove channel', 'danger'); } } /** * Share channel (show QR code) */ async function shareChannel(index) { try { const response = await fetch(`/api/channels/${index}/qr`); const data = await response.json(); if (data.success) { // Populate share modal document.getElementById('shareChannelName').textContent = `Channel: ${data.qr_data.name}`; document.getElementById('shareChannelQR').src = data.qr_image; document.getElementById('shareChannelKey').value = data.qr_data.key; // Show modal const modal = new bootstrap.Modal(document.getElementById('shareChannelModal')); modal.show(); } else { showNotification('Failed to generate QR code: ' + data.error, 'danger'); } } catch (error) { showNotification('Failed to generate QR code', 'danger'); } } /** * Copy channel key to clipboard */ async function copyChannelKey() { const input = document.getElementById('shareChannelKey'); try { // Use modern Clipboard API await navigator.clipboard.writeText(input.value); showNotification('Channel key copied to clipboard!', 'success'); } catch (error) { // Fallback for older browsers input.select(); try { document.execCommand('copy'); showNotification('Channel key copied to clipboard!', 'success'); } catch (fallbackError) { showNotification('Failed to copy to clipboard', 'danger'); } } } // ============================================================================= // Direct Messages (DM) Functions // ============================================================================= /** * Load DM last seen timestamps from server */ async function loadDmLastSeenTimestampsFromServer() { try { const response = await fetch('/api/read_status'); const data = await response.json(); if (data.success && data.dm) { dmLastSeenTimestamps = data.dm; console.log('Loaded DM read status from server:', Object.keys(dmLastSeenTimestamps).length, 'conversations'); } else { console.warn('Failed to load DM read status from server, using empty state'); dmLastSeenTimestamps = {}; } } catch (error) { console.error('Error loading DM read status from server:', error); dmLastSeenTimestamps = {}; } } /** * Save DM read status to server */ async function saveDmReadStatus(conversationId, timestamp) { try { const response = await fetch('/api/read_status/mark_read', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'dm', conversation_id: conversationId, timestamp: timestamp }) }); const data = await response.json(); if (!data.success) { console.error('Failed to save DM read status:', data.error); } } catch (error) { console.error('Error saving DM read status:', error); } } /** * Start DM from channel message (DM button click) * Redirects to the full-page DM view */ function startDmTo(username) { const conversationId = `name_${username}`; window.location.href = `/dm?conversation=${encodeURIComponent(conversationId)}`; } /** * Check for new DMs (called by auto-refresh) */ async function checkDmUpdates() { try { const lastSeenParam = encodeURIComponent(JSON.stringify(dmLastSeenTimestamps)); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); const response = await fetch(`/api/dm/updates?last_seen=${lastSeenParam}`, { signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) return; const data = await response.json(); if (data.success) { // Update unread counts dmUnreadCounts = {}; if (data.conversations) { data.conversations.forEach(conv => { dmUnreadCounts[conv.conversation_id] = conv.unread_count; }); } // Update badges updateDmBadges(data.total_unread || 0); // Update app icon badge updateAppBadge(); } } catch (error) { if (error.name !== 'AbortError') { console.error('Error checking DM updates:', error); } } } /** * Update DM notification badges */ function updateDmBadges(totalUnread) { // Update menu badge (legacy element, kept for backwards compat) const menuBadge = document.getElementById('dmMenuBadge'); if (menuBadge) { if (totalUnread > 0) { menuBadge.textContent = totalUnread > 99 ? '99+' : totalUnread; menuBadge.style.display = 'inline-block'; } else { menuBadge.style.display = 'none'; } } // Update FAB badge (red badge on Direct Messages FAB) updateFabBadge('.fab-dm', 'fab-badge-dm', totalUnread); // Update Main Menu copy badge (#menu-dm .menu-badge-dm) — kept in sync regardless of placement const menuDmBadge = document.querySelector('#menu-dm .menu-badge-dm'); if (menuDmBadge) { if (totalUnread > 0) { menuDmBadge.textContent = totalUnread > 99 ? '99+' : totalUnread; menuDmBadge.classList.remove('d-none'); } else { menuDmBadge.classList.add('d-none'); } } } /** * Update pending contacts badge on Contact Management FAB button * Fetches count from API using type filter from localStorage */ function setMenuContactsBadge(count) { const badge = document.querySelector('#menu-contacts .menu-badge-contacts'); if (!badge) return; if (count > 0) { badge.textContent = count > 99 ? '99+' : count; badge.classList.remove('d-none'); } else { badge.classList.add('d-none'); } } async function updatePendingContactsBadge() { try { // Suppress: hide FAB badge entirely, skip browser notification path if (window.contactsSettings?.suppress_advert_notifications) { updateFabBadge('.fab-contacts', 'fab-badge-pending', 0); setMenuContactsBadge(0); updateAppBadge(); return; } // Load type filter from localStorage (uses same function as contacts.js) const savedTypes = loadPendingTypeFilter(); // Build query string with types parameter const params = new URLSearchParams(); savedTypes.forEach(type => params.append('types', type)); // Fetch pending count with type filter const response = await fetch(`/api/contacts/pending?${params.toString()}`); if (!response.ok) return; const data = await response.json(); if (data.success) { const count = data.pending?.length || 0; // Update FAB badge (red badge on Contact Management button) updateFabBadge('.fab-contacts', 'fab-badge-pending', count); // Update Main Menu copy badge setMenuContactsBadge(count); // Update app icon badge updateAppBadge(); } } catch (error) { console.error('Error updating pending contacts badge:', error); } } /** * Load pending contacts type filter from localStorage. * This is a duplicate of the function in contacts.js for use in app.js * @returns {Array} Array of contact types (default: [1] for COM only) */ function loadPendingTypeFilter() { try { const stored = localStorage.getItem('pendingContactsTypeFilter'); if (stored) { const types = JSON.parse(stored); // Validate: must be array of valid types if (Array.isArray(types) && types.every(t => [1, 2, 3, 4].includes(t))) { return types; } } } catch (e) { console.error('Failed to load pending type filter from localStorage:', e); } // Default: COM only (most common use case) return [1]; } // ============================================================================= // Mentions Autocomplete Functions // ============================================================================= /** * Setup mentions autocomplete functionality */ function setupMentionsAutocomplete() { const input = document.getElementById('messageInput'); const popup = document.getElementById('mentionsPopup'); if (!input || !popup) { console.warn('[mentions] Required elements not found'); return; } // Track @ trigger on input input.addEventListener('input', handleMentionInput); // Handle keyboard navigation input.addEventListener('keydown', handleMentionKeydown); // Close popup on blur (with delay to allow click selection) input.addEventListener('blur', function() { setTimeout(hideMentionsPopup, 200); }); // Preload contacts on focus input.addEventListener('focus', function() { loadContactsForMentions(); }); // Click outside to close document.addEventListener('click', function(e) { if (!popup.contains(e.target) && e.target !== input) { hideMentionsPopup(); } }); console.log('[mentions] Autocomplete initialized'); } /** * Handle input event for mention detection */ function handleMentionInput(e) { const input = e.target; const cursorPos = input.selectionStart; const text = input.value; // Find @ character before cursor const textBeforeCursor = text.substring(0, cursorPos); const lastAtPos = textBeforeCursor.lastIndexOf('@'); // Check if we should be in mention mode if (lastAtPos >= 0) { // Check if there's a space or newline between @ and cursor (mention ended) const textAfterAt = textBeforeCursor.substring(lastAtPos + 1); // Allow alphanumeric, underscore, dash, emoji, and other non-whitespace chars in username // Space or newline ends the mention if (!/[\s\n]/.test(textAfterAt)) { // We're in mention mode mentionStartPos = lastAtPos; isMentionMode = true; const query = textAfterAt; showMentionsPopup(query); return; } } // Not in mention mode if (isMentionMode) { hideMentionsPopup(); } } /** * Handle keyboard navigation in mentions popup */ function handleMentionKeydown(e) { if (!isMentionMode) return; const popup = document.getElementById('mentionsPopup'); const items = popup.querySelectorAll('.mention-item'); if (items.length === 0) return; switch (e.key) { case 'ArrowDown': e.preventDefault(); mentionSelectedIndex = Math.min(mentionSelectedIndex + 1, items.length - 1); updateMentionHighlight(items); break; case 'ArrowUp': e.preventDefault(); mentionSelectedIndex = Math.max(mentionSelectedIndex - 1, 0); updateMentionHighlight(items); break; case 'Enter': case 'Tab': if (items.length > 0 && mentionSelectedIndex < items.length) { e.preventDefault(); const selected = items[mentionSelectedIndex]; if (selected && selected.dataset.contact) { selectMentionContact(selected.dataset.contact); } } break; case 'Escape': e.preventDefault(); hideMentionsPopup(); break; } } /** * Show mentions popup with filtered contacts */ function showMentionsPopup(query) { const popup = document.getElementById('mentionsPopup'); const list = document.getElementById('mentionsList'); // Filter contacts const filtered = filterContacts(query); if (filtered.length === 0) { list.innerHTML = '
    No contacts found
    '; popup.classList.remove('hidden'); return; } // Reset selection index if out of bounds if (mentionSelectedIndex >= filtered.length) { mentionSelectedIndex = 0; } // Build list HTML list.innerHTML = filtered.map((contact, index) => { const highlighted = index === mentionSelectedIndex ? 'highlighted' : ''; const escapedName = escapeHtml(contact); return `
    ${escapedName}
    `; }).join(''); // Add click handlers list.querySelectorAll('.mention-item').forEach(item => { item.addEventListener('click', function() { selectMentionContact(this.dataset.contact); }); }); // Close emoji picker if open (avoid overlapping popups) const emojiPopup = document.getElementById('emojiPickerPopup'); if (emojiPopup && !emojiPopup.classList.contains('hidden')) { emojiPopup.classList.add('hidden'); } popup.classList.remove('hidden'); } /** * Hide mentions popup and reset state */ function hideMentionsPopup() { const popup = document.getElementById('mentionsPopup'); if (popup) { popup.classList.add('hidden'); } isMentionMode = false; mentionStartPos = -1; mentionSelectedIndex = 0; } /** * Filter contacts by query (matches any part of name) */ function filterContacts(query) { if (!mentionsCache || mentionsCache.length === 0) { return []; } const lowerQuery = query.toLowerCase(); // Filter by any part of the name (not just prefix) return mentionsCache.filter(contact => contact.toLowerCase().includes(lowerQuery) ).slice(0, 10); // Limit to 10 results for performance } /** * Update highlight on mention items */ function updateMentionHighlight(items) { items.forEach((item, index) => { if (index === mentionSelectedIndex) { item.classList.add('highlighted'); // Scroll item into view if needed item.scrollIntoView({ block: 'nearest' }); } else { item.classList.remove('highlighted'); } }); } /** * Select a contact and insert mention into textarea */ function selectMentionContact(contactName) { const input = document.getElementById('messageInput'); const text = input.value; // Replace from @ position to cursor with @[contactName] const beforeMention = text.substring(0, mentionStartPos); const afterCursor = text.substring(input.selectionStart); const mention = `@[${contactName}] `; input.value = beforeMention + mention + afterCursor; // Set cursor position after the mention const newCursorPos = mentionStartPos + mention.length; input.setSelectionRange(newCursorPos, newCursorPos); // Update character counter updateCharCounter(); // Hide popup and reset state hideMentionsPopup(); // Keep focus on input input.focus(); } /** * Load contacts for mentions autocomplete (with caching) */ async function loadContactsForMentions() { const CACHE_TTL = 60000; // 60 seconds const now = Date.now(); // Return cached if still valid if (mentionsCache.length > 0 && (now - mentionsCacheTimestamp) < CACHE_TTL) { return; } try { const response = await fetch('/api/contacts/cached'); const data = await response.json(); if (data.success && data.contacts) { mentionsCache = data.contacts; mentionsCacheTimestamp = now; console.log(`[mentions] Cached ${mentionsCache.length} contacts from cache`); } } catch (error) { console.error('[mentions] Error loading contacts:', error); } } // ============================================================================= // FAB Toggle (Collapse/Expand) // ============================================================================= // ============================================================================= // Item placement (Quick Access vs Main Menu) // ============================================================================= const ITEM_PLACEMENT_DEFS = { filter: { fab: '#filterFab', menu: '#menu-filter' }, search: { fab: '#globalSearchBtn', menu: '#menu-search' }, dm: { fab: '.fab-dm', menu: '#menu-dm' }, contacts: { fab: '.fab-contacts', menu: '#menu-contacts' }, settings: { fab: '.fab-settings', menu: '#menu-settings' }, advert: { fab: '#fab-advert', menu: '#advertBtn' }, floodadvert: { fab: '#fab-floodadvert', menu: '#floodadvBtn' }, map: { fab: '#fab-map', menu: '#mapBtn' }, console: { fab: '#fab-console', menu: '#consoleBtn' }, deviceinfo: { fab: '#fab-deviceinfo', menu: '#deviceInfoBtn' }, syslog: { fab: '#fab-syslog', menu: '#logsBtn' }, }; const ITEM_PLACEMENT_DEFAULTS = { filter: 'fab', search: 'fab', dm: 'fab', contacts: 'fab', settings: 'fab', advert: 'menu', floodadvert: 'menu', map: 'menu', console: 'menu', deviceinfo: 'menu', syslog: 'menu' }; function readItemPlacements() { let stored = {}; try { stored = JSON.parse(localStorage.getItem('mc-webui-item-placements') || '{}'); } catch (e) { stored = {}; } return { ...ITEM_PLACEMENT_DEFAULTS, ...stored }; } function isFabHidden() { return localStorage.getItem('mc-webui-fab-hidden') === 'true'; } function applyItemPlacements() { const hidden = isFabHidden(); const placements = readItemPlacements(); const fabContainer = document.getElementById('fabContainer'); if (fabContainer) { fabContainer.classList.toggle('d-none', hidden); } for (const [key, sels] of Object.entries(ITEM_PLACEMENT_DEFS)) { const place = hidden ? 'menu' : placements[key]; const fabEl = document.querySelector(sels.fab); const menuEl = document.querySelector(sels.menu); if (fabEl) fabEl.classList.toggle('d-none', place !== 'fab'); if (menuEl) menuEl.classList.toggle('d-none', place !== 'menu'); } } function syncPlacementSettingsUI() { const hideCheckbox = document.getElementById('settHideFab'); if (hideCheckbox) hideCheckbox.checked = isFabHidden(); const placements = readItemPlacements(); for (const key of Object.keys(ITEM_PLACEMENT_DEFS)) { const radio = document.querySelector(`input[name="place-${key}"][value="${placements[key]}"]`); if (radio) radio.checked = true; } applyPlacementControlsDisabled(); } function applyPlacementControlsDisabled() { const wrap = document.getElementById('fabAppearanceControls'); if (!wrap) return; const disabled = isFabHidden(); wrap.style.opacity = disabled ? '0.5' : ''; wrap.style.pointerEvents = disabled ? 'none' : ''; wrap.querySelectorAll('input, button').forEach(el => { el.disabled = disabled; }); } function initializeItemPlacementSettings() { const hideCheckbox = document.getElementById('settHideFab'); if (hideCheckbox) { hideCheckbox.addEventListener('change', () => { localStorage.setItem('mc-webui-fab-hidden', hideCheckbox.checked ? 'true' : 'false'); applyPlacementControlsDisabled(); applyItemPlacements(); }); } for (const key of Object.keys(ITEM_PLACEMENT_DEFS)) { document.querySelectorAll(`input[name="place-${key}"]`).forEach(radio => { radio.addEventListener('change', () => { if (!radio.checked) return; const placements = readItemPlacements(); placements[key] = radio.value; localStorage.setItem('mc-webui-item-placements', JSON.stringify(placements)); applyItemPlacements(); }); }); } const settingsModal = document.getElementById('settingsModal'); if (settingsModal) { settingsModal.addEventListener('show.bs.modal', syncPlacementSettingsUI); } } function initializeFabToggle() { const toggle = document.getElementById('fabToggle'); const container = document.getElementById('fabContainer'); if (!toggle || !container) return; // Restore collapsed state if (localStorage.getItem('mc-webui-fab-collapsed') === '1') { container.classList.add('collapsed'); toggle.title = 'Show buttons'; } toggle.addEventListener('click', () => { container.classList.toggle('collapsed'); const isCollapsed = container.classList.contains('collapsed'); toggle.title = isCollapsed ? 'Show buttons' : 'Hide buttons'; localStorage.setItem('mc-webui-fab-collapsed', isCollapsed ? '1' : '0'); }); // Drag-and-drop support initFabDrag('fabContainer', 'fabToggle', 'mc-webui-fab-pos'); // Listen for settings open request from DM iframe window.addEventListener('message', (e) => { if (e.data && e.data.type === 'openSettings') { const modal = document.getElementById('settingsModal'); if (modal) { const bsModal = bootstrap.Modal.getOrCreateInstance(modal); bsModal.show(); } } }); } // ============================================================================= // Sidebar breakpoint (channel/DM list as sidebar vs. dropdown) // ============================================================================= const SIDEBAR_BREAKPOINT_DEFAULT = 992; const SIDEBAR_BREAKPOINT_MIN = 600; const SIDEBAR_BREAKPOINT_MAX = 2000; const SIDEBAR_BREAKPOINT_KEY = 'mc-webui-sidebar-breakpoint'; function readSidebarBreakpoint() { const stored = parseInt(localStorage.getItem(SIDEBAR_BREAKPOINT_KEY), 10); if (isNaN(stored) || stored < SIDEBAR_BREAKPOINT_MIN || stored > SIDEBAR_BREAKPOINT_MAX) { return SIDEBAR_BREAKPOINT_DEFAULT; } return stored; } function applySidebarBreakpoint() { const bp = readSidebarBreakpoint(); document.documentElement.classList.toggle('layout-wide', window.innerWidth >= bp); } let _sidebarBreakpointRaf = null; function onSidebarBreakpointResize() { if (_sidebarBreakpointRaf) return; _sidebarBreakpointRaf = requestAnimationFrame(() => { _sidebarBreakpointRaf = null; applySidebarBreakpoint(); }); } function syncSidebarBreakpointUI() { const input = document.getElementById('settSidebarBreakpoint'); if (input) input.value = readSidebarBreakpoint(); } function initializeSidebarBreakpointSettings() { window.addEventListener('resize', onSidebarBreakpointResize); const input = document.getElementById('settSidebarBreakpoint'); if (input) { input.addEventListener('input', () => { const val = parseInt(input.value, 10); if (isNaN(val) || val < SIDEBAR_BREAKPOINT_MIN || val > SIDEBAR_BREAKPOINT_MAX) return; localStorage.setItem(SIDEBAR_BREAKPOINT_KEY, String(val)); applySidebarBreakpoint(); }); } const resetBtn = document.getElementById('settSidebarBreakpointReset'); if (resetBtn) { resetBtn.addEventListener('click', () => { localStorage.removeItem(SIDEBAR_BREAKPOINT_KEY); if (input) input.value = SIDEBAR_BREAKPOINT_DEFAULT; applySidebarBreakpoint(); }); } const settingsModal = document.getElementById('settingsModal'); if (settingsModal) { settingsModal.addEventListener('show.bs.modal', syncSidebarBreakpointUI); } } // ============================================================================= // Chat Filter Functionality // ============================================================================= // Filter state let filterActive = false; let currentFilterQuery = ''; let originalMessageContents = new Map(); /** * Initialize filter functionality */ function initializeFilter() { const filterFab = document.getElementById('filterFab'); const filterBar = document.getElementById('filterBar'); const filterInput = document.getElementById('filterInput'); const filterClearBtn = document.getElementById('filterClearBtn'); const filterCloseBtn = document.getElementById('filterCloseBtn'); if (!filterFab || !filterBar) return; // Open filter bar when FAB clicked filterFab.addEventListener('click', () => { openFilterBar(); }); // "Filter my messages" button - inserts current device name const filterMeBtn = document.getElementById('filterMeBtn'); if (filterMeBtn) { filterMeBtn.addEventListener('click', () => { const deviceName = window.MC_CONFIG?.deviceName || ''; if (deviceName) { filterInput.value = deviceName; applyFilter(deviceName); filterInput.focus(); } }); } // Filter as user types (debounced) - also check for @mentions let filterTimeout = null; filterInput.addEventListener('input', () => { // Check for @mention trigger if (handleFilterMentionInput(filterInput)) { return; // Don't apply filter while picking a mention } clearTimeout(filterTimeout); filterTimeout = setTimeout(() => { applyFilter(filterInput.value); }, 150); }); // Clear filter filterClearBtn.addEventListener('click', () => { filterInput.value = ''; applyFilter(''); hideFilterMentionsPopup(); filterInput.focus(); }); // Close filter bar filterCloseBtn.addEventListener('click', () => { closeFilterBar(); }); // Keyboard shortcuts (with mentions navigation support) filterInput.addEventListener('keydown', (e) => { // If filter mentions popup is active, handle navigation if (filterMentionActive) { if (handleFilterMentionKeydown(e)) return; } if (e.key === 'Escape') { if (filterMentionActive) { hideFilterMentionsPopup(); e.preventDefault(); } else { closeFilterBar(); } } }); // Close filter mentions on blur filterInput.addEventListener('blur', () => { setTimeout(hideFilterMentionsPopup, 200); }); // Preload contacts when filter bar is focused filterInput.addEventListener('focus', () => { loadContactsForMentions(); }); // Global keyboard shortcut: Ctrl+F to open filter document.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'f') { e.preventDefault(); openFilterBar(); } }); } /** * Open the filter bar */ function openFilterBar() { const filterBar = document.getElementById('filterBar'); const filterInput = document.getElementById('filterInput'); filterBar.classList.add('visible'); filterActive = true; // Focus input after animation setTimeout(() => { filterInput.focus(); }, 100); } /** * Close the filter bar and reset filter */ function closeFilterBar() { const filterBar = document.getElementById('filterBar'); const filterInput = document.getElementById('filterInput'); filterBar.classList.remove('visible'); filterActive = false; hideFilterMentionsPopup(); // Reset filter filterInput.value = ''; applyFilter(''); } /** * Apply filter to messages * @param {string} query - Search query */ function applyFilter(query) { currentFilterQuery = query.trim(); const container = document.getElementById('messagesList'); const messages = container.querySelectorAll('.message-wrapper'); const matchCountEl = document.getElementById('filterMatchCount'); // Remove any existing no-matches message const existingNoMatches = container.querySelector('.filter-no-matches'); if (existingNoMatches) { existingNoMatches.remove(); } if (!currentFilterQuery) { // No filter - show all messages, restore original content messages.forEach(msg => { msg.classList.remove('filter-hidden'); restoreOriginalContent(msg); }); matchCountEl.textContent = ''; return; } let matchCount = 0; messages.forEach(msg => { // Get text content from message const text = FilterUtils.getMessageText(msg, '.message-content'); const senderEl = msg.querySelector('.message-sender'); const senderText = senderEl ? senderEl.textContent : ''; // Check if message matches (content or sender) const matches = FilterUtils.textMatches(text, currentFilterQuery) || FilterUtils.textMatches(senderText, currentFilterQuery); if (matches) { msg.classList.remove('filter-hidden'); matchCount++; // Highlight matches in content highlightMessageContent(msg); } else { msg.classList.add('filter-hidden'); restoreOriginalContent(msg); } }); // Update match count matchCountEl.textContent = `${matchCount} / ${messages.length}`; // Show no matches message if needed if (matchCount === 0 && messages.length > 0) { const noMatchesDiv = document.createElement('div'); noMatchesDiv.className = 'filter-no-matches'; noMatchesDiv.innerHTML = `

    No messages match "${escapeHtml(currentFilterQuery)}"

    `; container.appendChild(noMatchesDiv); } } /** * Highlight matching text in a message element * @param {HTMLElement} messageEl - Message wrapper element */ function highlightMessageContent(messageEl) { const contentEl = messageEl.querySelector('.message-content'); if (!contentEl) return; // Store original content if not already stored const msgId = getMessageId(messageEl); if (!originalMessageContents.has(msgId)) { originalMessageContents.set(msgId, contentEl.innerHTML); } // Get original content and apply highlighting const originalHtml = originalMessageContents.get(msgId); contentEl.innerHTML = FilterUtils.highlightMatches(originalHtml, currentFilterQuery); // Also highlight sender name if present const senderEl = messageEl.querySelector('.message-sender'); if (senderEl) { const senderMsgId = msgId + '_sender'; if (!originalMessageContents.has(senderMsgId)) { originalMessageContents.set(senderMsgId, senderEl.innerHTML); } const originalSenderHtml = originalMessageContents.get(senderMsgId); senderEl.innerHTML = FilterUtils.highlightMatches(originalSenderHtml, currentFilterQuery); } } /** * Restore original content of a message element * @param {HTMLElement} messageEl - Message wrapper element */ function restoreOriginalContent(messageEl) { const contentEl = messageEl.querySelector('.message-content'); const senderEl = messageEl.querySelector('.message-sender'); const msgId = getMessageId(messageEl); if (contentEl && originalMessageContents.has(msgId)) { contentEl.innerHTML = originalMessageContents.get(msgId); } if (senderEl && originalMessageContents.has(msgId + '_sender')) { senderEl.innerHTML = originalMessageContents.get(msgId + '_sender'); } } /** * Generate a unique ID for a message element * @param {HTMLElement} messageEl - Message element * @returns {string} - Unique identifier */ function getMessageId(messageEl) { const parent = messageEl.parentNode; const children = Array.from(parent.children).filter(el => el.classList.contains('message-wrapper')); return 'msg_' + children.indexOf(messageEl); } // ============================================================================= // Filter Mentions Autocomplete // ============================================================================= let filterMentionActive = false; let filterMentionStartPos = -1; let filterMentionSelectedIndex = 0; /** * Handle input in filter bar to detect @mention trigger * @returns {boolean} true if in mention mode (caller should skip filter apply) */ function handleFilterMentionInput(input) { const cursorPos = input.selectionStart; const text = input.value; const textBeforeCursor = text.substring(0, cursorPos); const lastAtPos = textBeforeCursor.lastIndexOf('@'); if (lastAtPos >= 0) { const textAfterAt = textBeforeCursor.substring(lastAtPos + 1); // No whitespace after @ means we're typing a mention if (!/[\s\n]/.test(textAfterAt)) { filterMentionStartPos = lastAtPos; filterMentionActive = true; showFilterMentionsPopup(textAfterAt); return true; } } if (filterMentionActive) { hideFilterMentionsPopup(); } return false; } /** * Handle keyboard navigation in filter mentions popup * @returns {boolean} true if the key was handled */ function handleFilterMentionKeydown(e) { const popup = document.getElementById('filterMentionsPopup'); const items = popup.querySelectorAll('.mention-item'); if (items.length === 0) return false; switch (e.key) { case 'ArrowDown': e.preventDefault(); filterMentionSelectedIndex = Math.min(filterMentionSelectedIndex + 1, items.length - 1); updateFilterMentionHighlight(items); return true; case 'ArrowUp': e.preventDefault(); filterMentionSelectedIndex = Math.max(filterMentionSelectedIndex - 1, 0); updateFilterMentionHighlight(items); return true; case 'Enter': case 'Tab': if (items.length > 0 && filterMentionSelectedIndex < items.length) { e.preventDefault(); const selected = items[filterMentionSelectedIndex]; if (selected && selected.dataset.contact) { selectFilterMentionContact(selected.dataset.contact); } return true; } break; } return false; } /** * Show filter mentions popup with filtered contacts */ function showFilterMentionsPopup(query) { const popup = document.getElementById('filterMentionsPopup'); const list = document.getElementById('filterMentionsList'); // Ensure contacts are loaded loadContactsForMentions(); const filtered = filterContacts(query); if (filtered.length === 0) { list.innerHTML = '
    No contacts found
    '; popup.classList.remove('hidden'); return; } if (filterMentionSelectedIndex >= filtered.length) { filterMentionSelectedIndex = 0; } list.innerHTML = filtered.map((contact, index) => { const highlighted = index === filterMentionSelectedIndex ? 'highlighted' : ''; const escapedName = escapeHtml(contact); return `
    ${escapedName}
    `; }).join(''); list.querySelectorAll('.mention-item').forEach(item => { item.addEventListener('click', function() { selectFilterMentionContact(this.dataset.contact); }); }); popup.classList.remove('hidden'); } /** * Hide filter mentions popup */ function hideFilterMentionsPopup() { const popup = document.getElementById('filterMentionsPopup'); if (popup) popup.classList.add('hidden'); filterMentionActive = false; filterMentionStartPos = -1; filterMentionSelectedIndex = 0; } /** * Update highlight in filter mentions popup */ function updateFilterMentionHighlight(items) { items.forEach((item, index) => { if (index === filterMentionSelectedIndex) { item.classList.add('highlighted'); item.scrollIntoView({ block: 'nearest' }); } else { item.classList.remove('highlighted'); } }); } /** * Select a contact from filter mentions and insert plain name */ function selectFilterMentionContact(contactName) { const input = document.getElementById('filterInput'); const text = input.value; // Replace from @ position to cursor with plain contact name const beforeMention = text.substring(0, filterMentionStartPos); const afterCursor = text.substring(input.selectionStart); input.value = beforeMention + contactName + afterCursor; // Set cursor position after the name const newCursorPos = filterMentionStartPos + contactName.length; input.setSelectionRange(newCursorPos, newCursorPos); hideFilterMentionsPopup(); input.focus(); // Trigger filter with the new value applyFilter(input.value); } /** * Clear filter state when messages are reloaded * Called from displayMessages() */ function clearFilterState() { originalMessageContents.clear(); // Re-apply filter if active if (filterActive && currentFilterQuery) { setTimeout(() => { applyFilter(currentFilterQuery); }, 50); } } // ============================================================================= // Global Message Search (FTS5) // ============================================================================= let searchDebounceTimer = null; function initializeSearch() { const input = document.getElementById('searchInput'); const btn = document.getElementById('searchBtn'); if (!input || !btn) return; // Toggle search help const helpBtn = document.getElementById('searchHelpBtn'); const helpPanel = document.getElementById('searchHelp'); if (helpBtn && helpPanel) { helpBtn.addEventListener('click', () => { helpPanel.style.display = helpPanel.style.display === 'none' ? '' : 'none'; }); } // Search on Enter or button click btn.addEventListener('click', () => performSearch(input.value)); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') performSearch(input.value); }); // Debounced search as user types (300ms) input.addEventListener('input', () => { clearTimeout(searchDebounceTimer); searchDebounceTimer = setTimeout(() => { if (input.value.trim().length >= 2) { performSearch(input.value); } }, 300); }); // Focus input when modal opens document.getElementById('searchModal')?.addEventListener('shown.bs.modal', () => { input.focus(); }); } async function performSearch(query) { query = query.trim(); const container = document.getElementById('searchResults'); if (!container) return; if (query.length < 2) { container.innerHTML = '

    Type at least 2 characters to search

    '; return; } container.innerHTML = '
    Searching...
    '; try { const response = await fetch(`/api/messages/search?q=${encodeURIComponent(query)}&limit=50`); const data = await response.json(); if (!data.success) { container.innerHTML = `
    ${escapeHtml(data.error)}
    `; return; } if (data.results.length === 0) { container.innerHTML = `

    No results for "${escapeHtml(query)}"

    `; return; } container.innerHTML = `
    ${data.count} result${data.count !== 1 ? 's' : ''}
    `; const list = document.createElement('div'); list.className = 'list-group'; data.results.forEach(r => { const item = document.createElement('a'); item.className = 'list-group-item list-group-item-action'; item.style.cursor = 'pointer'; const time = formatTime(r.timestamp); const snippet = highlightSearchTerm(escapeHtml(r.content), query); if (r.source === 'channel') { item.innerHTML = `
    ${escapeHtml(r.channel_name || '')} ${r.is_own ? 'You' : escapeHtml(r.sender || '')}
    ${time}
    ${snippet}
    `; item.addEventListener('click', () => { // Navigate to channel const channels = window._channelDropdownItems || []; const ch = channels.find(c => c && c.index === r.channel_idx); selectChannelFromDropdown(r.channel_idx, ch ? ch.name : (r.channel_name || '')); bootstrap.Modal.getInstance(document.getElementById('searchModal'))?.hide(); }); } else { item.innerHTML = `
    DM ${escapeHtml(r.contact_name || '')} ${r.direction === 'out' ? '(sent)' : '(received)'}
    ${time}
    ${snippet}
    `; item.addEventListener('click', () => { // Navigate to DM conversation window.location.href = `/dm?conversation=${encodeURIComponent(r.contact_pubkey)}`; }); } list.appendChild(item); }); container.appendChild(list); } catch (error) { console.error('Search error:', error); container.innerHTML = '
    Search failed. Please try again.
    '; } } function highlightSearchTerm(html, query) { if (!query) return html; const normalizedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const regex = new RegExp(`(${normalizedQuery})`, 'gi'); return html.replace(regex, '$1'); } // Initialize search when DOM is ready document.addEventListener('DOMContentLoaded', initializeSearch); // ============================================================================= // Backup Management // ============================================================================= function initializeBackup() { const modal = document.getElementById('backupModal'); if (!modal) return; modal.addEventListener('shown.bs.modal', () => { loadBackupList(); loadDatabaseSize(); }); } function _formatBytes(n) { if (!Number.isFinite(n) || n < 0) return '?'; if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`; return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`; } async function loadDatabaseSize() { const statusEl = document.getElementById('vacuumDbStatus'); if (!statusEl) return; try { const response = await fetch('/api/db/size'); const data = await response.json(); if (data.success) { statusEl.textContent = `Current size: ${_formatBytes(data.size)}`; } else { statusEl.textContent = 'Size: unknown'; } } catch (error) { statusEl.textContent = 'Size: unknown'; } } async function optimizeDatabase() { const btn = document.getElementById('vacuumDbBtn'); const statusEl = document.getElementById('vacuumDbStatus'); if (!btn) return; btn.disabled = true; btn.innerHTML = '
    Optimizing…'; if (statusEl) statusEl.textContent = 'Running VACUUM…'; const restoreButton = () => { btn.disabled = false; btn.innerHTML = ' Optimize now'; }; try { const kickoff = await fetch('/api/db/vacuum', { method: 'POST' }); const kickoffData = await kickoff.json().catch(() => ({})); if (!kickoff.ok && kickoff.status !== 409) { showNotification('Optimize failed: ' + (kickoffData.error || `HTTP ${kickoff.status}`), 'danger'); loadDatabaseSize(); restoreButton(); return; } // 409 means another VACUUM is already running — we just attach to it. // Poll status every 2s. Cap at 10 minutes to avoid an infinite spinner // if something goes really wrong on the server side. const POLL_INTERVAL_MS = 2000; const MAX_POLLS = 300; for (let i = 0; i < MAX_POLLS; i++) { await new Promise(r => setTimeout(r, POLL_INTERVAL_MS)); let status; try { const resp = await fetch('/api/db/vacuum/status'); status = await resp.json(); } catch (e) { continue; // transient — try again } if (status.running) { if (statusEl) statusEl.textContent = `Running VACUUM… (${status.elapsed_seconds || 0}s)`; continue; } // Done — either success or error. if (status.success === true && status.size_after !== undefined) { const freed = status.freed > 0 ? `freed ${_formatBytes(status.freed)}` : 'no space to reclaim'; showNotification(`Optimized: ${freed} in ${status.elapsed_seconds}s`, 'success'); if (statusEl) statusEl.textContent = `Current size: ${_formatBytes(status.size_after)}`; } else if (status.error) { showNotification('Optimize failed: ' + status.error, 'danger'); loadDatabaseSize(); } else { // No result, no error, not running — odd, just refresh size loadDatabaseSize(); } restoreButton(); return; } showNotification('Optimize is still running after 10 minutes — check container logs', 'warning'); loadDatabaseSize(); restoreButton(); } catch (error) { console.error('Error running VACUUM:', error); showNotification('Optimize failed', 'danger'); loadDatabaseSize(); restoreButton(); } } async function loadBackupList() { const container = document.getElementById('backupList'); const statusEl = document.getElementById('backupAutoStatus'); if (!container) return; container.innerHTML = '
    Loading...
    '; try { const response = await fetch('/api/backup/list'); const data = await response.json(); if (!data.success) { container.innerHTML = `
    ${escapeHtml(data.error)}
    `; return; } // Show auto-backup status if (statusEl) { statusEl.textContent = data.auto_backup_enabled ? `Auto: daily at ${String(data.backup_hour).padStart(2, '0')}:00, keep ${data.retention_days}d` : 'Auto-backup disabled'; } if (data.backups.length === 0) { container.innerHTML = '

    No backups yet

    '; return; } const list = document.createElement('div'); list.className = 'list-group'; data.backups.forEach(b => { const item = document.createElement('div'); item.className = 'list-group-item d-flex justify-content-between align-items-center'; item.innerHTML = `
    ${escapeHtml(b.filename)} ${b.size_display}
    `; list.appendChild(item); }); container.innerHTML = ''; container.appendChild(list); } catch (error) { console.error('Error loading backups:', error); container.innerHTML = '
    Failed to load backups
    '; } } async function createBackup() { const btn = document.getElementById('createBackupBtn'); if (!btn) return; btn.disabled = true; btn.innerHTML = '
    Creating...'; try { const response = await fetch('/api/backup/create', { method: 'POST' }); const data = await response.json(); if (data.success) { showNotification(`Backup created: ${data.filename}`, 'success'); loadBackupList(); } else { showNotification('Backup failed: ' + data.error, 'danger'); } } catch (error) { console.error('Error creating backup:', error); showNotification('Backup failed', 'danger'); } finally { btn.disabled = false; btn.innerHTML = ' Create Backup'; } } document.addEventListener('DOMContentLoaded', initializeBackup);