No contacts match filters
Try changing your filter criteria `; listEl.appendChild(emptyDiv); return; } contacts.forEach((contact, index) => { const card = createContactCard(contact, index); listEl.appendChild(card); }); } function createContactCard(contact, index) { const card = document.createElement('div'); card.className = 'pending-contact-card'; card.id = `contact-${index}`; // Contact info row (name + type badge) const infoRow = document.createElement('div'); infoRow.className = 'contact-info-row'; const nameDiv = document.createElement('div'); nameDiv.className = 'contact-name flex-grow-1'; nameDiv.textContent = contact.name; const typeBadge = document.createElement('span'); typeBadge.className = 'badge type-badge'; typeBadge.textContent = contact.type_label || 'COM'; // Color-code by type (same as existing contacts) switch (contact.type_label) { case 'COM': typeBadge.classList.add('bg-primary'); break; case 'REP': typeBadge.classList.add('bg-success'); break; case 'ROOM': typeBadge.classList.add('bg-info'); break; case 'SENS': typeBadge.classList.add('bg-warning', 'text-dark'); break; default: typeBadge.classList.add('bg-secondary'); } infoRow.appendChild(nameDiv); infoRow.appendChild(typeBadge); // Public key row (clickable to copy) const keyDiv = document.createElement('div'); keyDiv.className = 'contact-key clickable-key'; keyDiv.textContent = contact.public_key_prefix || contact.public_key.substring(0, 12); keyDiv.title = 'Click to copy'; keyDiv.onclick = () => copyToClipboard(keyDiv.textContent, keyDiv); // Last advert (optional - show if available) let lastAdvertDiv = null; if (contact.last_advert) { lastAdvertDiv = document.createElement('div'); lastAdvertDiv.className = 'text-muted small'; const relativeTime = formatRelativeTime(contact.last_advert); lastAdvertDiv.textContent = `Last seen: ${relativeTime}`; } // Action buttons const actionsDiv = document.createElement('div'); actionsDiv.className = 'd-flex gap-2 mt-2'; // Approve button const approveBtn = document.createElement('button'); approveBtn.className = 'btn btn-sm btn-success'; approveBtn.innerHTML = ' Approve'; approveBtn.onclick = () => approveContact(contact, index); actionsDiv.appendChild(approveBtn); // Map button (only if GPS coordinates available) if (contact.adv_lat && contact.adv_lon && (contact.adv_lat !== 0 || contact.adv_lon !== 0)) { const mapBtn = document.createElement('button'); mapBtn.className = 'btn btn-sm btn-outline-primary'; mapBtn.innerHTML = ' Map'; mapBtn.onclick = () => window.showContactOnMap(contact.name, contact.adv_lat, contact.adv_lon); actionsDiv.appendChild(mapBtn); } // Ignore button const ignoreBtn = document.createElement('button'); ignoreBtn.className = 'btn btn-sm btn-outline-secondary'; ignoreBtn.innerHTML = ' Ignore'; ignoreBtn.onclick = () => { toggleContactIgnore(contact.public_key, true).then(() => loadPendingContacts()); }; actionsDiv.appendChild(ignoreBtn); // Block button const blockBtn = document.createElement('button'); blockBtn.className = 'btn btn-sm btn-outline-danger'; blockBtn.innerHTML = ' Block'; blockBtn.onclick = () => { toggleContactBlock(contact.public_key, true).then(() => loadPendingContacts()); }; actionsDiv.appendChild(blockBtn); // Assemble card card.appendChild(infoRow); card.appendChild(keyDiv); if (lastAdvertDiv) card.appendChild(lastAdvertDiv); card.appendChild(actionsDiv); return card; } async function approveContact(contact, index) { const cardEl = document.getElementById(`contact-${index}`); // Disable buttons during approval if (cardEl) { const buttons = cardEl.querySelectorAll('button'); buttons.forEach(btn => btn.disabled = true); } try { const response = await fetch('/api/contacts/pending/approve', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ public_key: contact.public_key // ALWAYS use full public_key (works for COM, ROOM, etc.) }) }); const data = await response.json(); if (data.success) { showToast(`Approved: ${contact.name}`, 'success'); // Remove from list with animation if (cardEl) { cardEl.style.opacity = '0'; cardEl.style.transition = 'opacity 0.3s'; setTimeout(() => { cardEl.remove(); // Reload pending list to update count loadPendingContacts(); }, 300); } } else { console.error('Failed to approve contact:', data.error); showToast('Failed to approve: ' + data.error, 'danger'); // Re-enable buttons if (cardEl) { const buttons = cardEl.querySelectorAll('button'); buttons.forEach(btn => btn.disabled = false); } } } catch (error) { console.error('Error approving contact:', error); showToast('Network error: ' + error.message, 'danger'); // Re-enable buttons if (cardEl) { const buttons = cardEl.querySelectorAll('button'); buttons.forEach(btn => btn.disabled = false); } } } function copyPublicKey(publicKey, buttonEl) { navigator.clipboard.writeText(publicKey).then(() => { // Visual feedback const originalHTML = buttonEl.innerHTML; buttonEl.innerHTML = ' Copied!'; buttonEl.classList.remove('btn-outline-secondary'); buttonEl.classList.add('btn-success'); setTimeout(() => { buttonEl.innerHTML = originalHTML; buttonEl.classList.remove('btn-success'); buttonEl.classList.add('btn-outline-secondary'); }, 2000); showToast('Public key copied to clipboard', 'info'); }).catch(err => { console.error('Failed to copy:', err); showToast('Failed to copy to clipboard', 'danger'); }); } // ============================================================================= // Pending Page - Filtering and Batch Approval // ============================================================================= function applyPendingFilters() { const searchInput = document.getElementById('pendingSearchInput'); const searchTerm = searchInput ? searchInput.value.toLowerCase() : ''; // Apply search filter locally (type filter already applied by API) filteredPendingContacts = pendingContacts.filter(contact => { // Search filter (name or public_key_prefix) if (searchTerm) { const nameMatch = contact.name.toLowerCase().includes(searchTerm); const keyMatch = (contact.public_key_prefix || contact.public_key).toLowerCase().includes(searchTerm); return nameMatch || keyMatch; } return true; }); // Render filtered list renderPendingList(filteredPendingContacts); } function showBatchApprovalModal() { if (filteredPendingContacts.length === 0) { showToast('No contacts to approve', 'warning'); return; } const modal = new bootstrap.Modal(document.getElementById('batchApprovalModal')); const countEl = document.getElementById('batchApprovalCount'); const listEl = document.getElementById('batchApprovalList'); // Update count if (countEl) countEl.textContent = filteredPendingContacts.length; // Populate list if (listEl) { listEl.innerHTML = ''; filteredPendingContacts.forEach(contact => { const item = document.createElement('div'); item.className = 'list-group-item d-flex justify-content-between align-items-center'; const nameSpan = document.createElement('span'); nameSpan.textContent = contact.name; const typeBadge = document.createElement('span'); typeBadge.className = 'badge'; typeBadge.textContent = contact.type_label; switch (contact.type_label) { case 'COM': typeBadge.classList.add('bg-primary'); break; case 'REP': typeBadge.classList.add('bg-success'); break; case 'ROOM': typeBadge.classList.add('bg-info'); break; case 'SENS': typeBadge.classList.add('bg-warning', 'text-dark'); break; default: typeBadge.classList.add('bg-secondary'); } item.appendChild(nameSpan); item.appendChild(typeBadge); listEl.appendChild(item); }); } modal.show(); } async function batchApproveContacts() { const modal = bootstrap.Modal.getInstance(document.getElementById('batchApprovalModal')); const confirmBtn = document.getElementById('confirmBatchApprovalBtn'); if (confirmBtn) confirmBtn.disabled = true; let successCount = 0; let failedCount = 0; const failures = []; // Approve contacts one by one (sequential HTTP requests) for (let i = 0; i < filteredPendingContacts.length; i++) { const contact = filteredPendingContacts[i]; // Update button with progress if (confirmBtn) { confirmBtn.innerHTML = ` Approving ${i + 1}/${filteredPendingContacts.length}...`; } try { const response = await fetch('/api/contacts/pending/approve', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ public_key: contact.public_key }) }); const data = await response.json(); if (data.success) { successCount++; } else { failedCount++; failures.push({ name: contact.name, error: data.error }); } } catch (error) { failedCount++; failures.push({ name: contact.name, error: error.message }); } } // Close modal if (modal) modal.hide(); // Show result if (successCount > 0 && failedCount === 0) { showToast(`Successfully approved ${successCount} contact${successCount !== 1 ? 's' : ''}`, 'success'); } else if (successCount > 0 && failedCount > 0) { showToast(`Approved ${successCount}, failed ${failedCount}. Check console for details.`, 'warning'); console.error('Failed approvals:', failures); } else { showToast(`Failed to approve contacts. Check console for details.`, 'danger'); console.error('Failed approvals:', failures); } // Reload pending list loadPendingContacts(); // Re-enable button if (confirmBtn) { confirmBtn.disabled = false; confirmBtn.innerHTML = ' Approve All'; } } function showBatchIgnoreModal() { if (filteredPendingContacts.length === 0) { showToast('No contacts to ignore', 'warning'); return; } const modal = new bootstrap.Modal(document.getElementById('batchIgnoreModal')); const countEl = document.getElementById('batchIgnoreCount'); const listEl = document.getElementById('batchIgnoreList'); if (countEl) countEl.textContent = filteredPendingContacts.length; if (listEl) { listEl.innerHTML = ''; filteredPendingContacts.forEach(contact => { const item = document.createElement('div'); item.className = 'list-group-item d-flex justify-content-between align-items-center'; const nameSpan = document.createElement('span'); nameSpan.textContent = contact.name; const typeBadge = document.createElement('span'); typeBadge.className = 'badge'; typeBadge.textContent = contact.type_label; switch (contact.type_label) { case 'COM': typeBadge.classList.add('bg-primary'); break; case 'REP': typeBadge.classList.add('bg-success'); break; case 'ROOM': typeBadge.classList.add('bg-info'); break; case 'SENS': typeBadge.classList.add('bg-warning', 'text-dark'); break; default: typeBadge.classList.add('bg-secondary'); } item.appendChild(nameSpan); item.appendChild(typeBadge); listEl.appendChild(item); }); } modal.show(); } async function batchIgnoreContacts() { const modal = bootstrap.Modal.getInstance(document.getElementById('batchIgnoreModal')); const confirmBtn = document.getElementById('confirmBatchIgnoreBtn'); if (confirmBtn) confirmBtn.disabled = true; let successCount = 0; let failedCount = 0; const failures = []; for (let i = 0; i < filteredPendingContacts.length; i++) { const contact = filteredPendingContacts[i]; if (confirmBtn) { confirmBtn.innerHTML = ` Ignoring ${i + 1}/${filteredPendingContacts.length}...`; } try { const response = await fetch(`/api/contacts/${encodeURIComponent(contact.public_key)}/ignore`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ignored: true }) }); const data = await response.json(); if (data.success) { successCount++; } else { failedCount++; failures.push({ name: contact.name, error: data.error }); } } catch (error) { failedCount++; failures.push({ name: contact.name, error: error.message }); } } if (modal) modal.hide(); if (successCount > 0 && failedCount === 0) { showToast(`Successfully ignored ${successCount} contact${successCount !== 1 ? 's' : ''}`, 'info'); } else if (successCount > 0 && failedCount > 0) { showToast(`Ignored ${successCount}, failed ${failedCount}. Check console for details.`, 'warning'); console.error('Failed ignores:', failures); } else { showToast(`Failed to ignore contacts. Check console for details.`, 'danger'); console.error('Failed ignores:', failures); } loadPendingContacts(); if (confirmBtn) { confirmBtn.disabled = false; confirmBtn.innerHTML = ' Ignore All'; } } // ============================================================================= // Toast Notifications // ============================================================================= function showToast(message, type = 'info') { const toastEl = document.getElementById('contactToast'); if (!toastEl) return; const bodyEl = toastEl.querySelector('.toast-body'); if (!bodyEl) return; // Set message and style bodyEl.textContent = message; // Apply color based on type toastEl.classList.remove('bg-success', 'bg-danger', 'bg-info', 'bg-warning'); toastEl.classList.remove('text-white'); if (type === 'success' || type === 'danger' || type === 'warning') { toastEl.classList.add(`bg-${type}`, 'text-white'); } else if (type === 'info') { toastEl.classList.add('bg-info', 'text-white'); } // Show toast const toast = new bootstrap.Toast(toastEl, { autohide: true, delay: 1500 }); toast.show(); } // ============================================================================= // Existing Contacts Management // ============================================================================= async function loadExistingContacts() { const loadingEl = document.getElementById('existingLoading'); const emptyEl = document.getElementById('existingEmpty'); const listEl = document.getElementById('existingList'); const errorEl = document.getElementById('existingError'); // Show loading state if (loadingEl) loadingEl.style.display = 'block'; if (emptyEl) emptyEl.style.display = 'none'; if (listEl) listEl.innerHTML = ''; if (errorEl) errorEl.style.display = 'none'; try { // Fetch device contacts and cached contacts in parallel const [deviceResponse, cacheResponse] = await Promise.all([ fetch('/api/contacts/detailed'), fetch('/api/contacts/cached?format=full') ]); const deviceData = await deviceResponse.json(); const cacheData = await cacheResponse.json(); if (loadingEl) loadingEl.style.display = 'none'; if (deviceData.success) { const deviceContacts = deviceData.contacts || []; const cachedContacts = (cacheData.success && cacheData.contacts) ? cacheData.contacts : []; // Mark device contacts const deviceKeySet = new Set(deviceContacts.map(c => c.public_key)); deviceContacts.forEach(c => { c.on_device = true; }); // Add cache-only contacts (not on device) const cacheOnlyContacts = cachedContacts .filter(c => !deviceKeySet.has(c.public_key)) .map(c => ({ name: c.name || 'Unknown', public_key: c.public_key, public_key_prefix: c.public_key_prefix || c.public_key.substring(0, 12), type_label: c.type_label || '', adv_lat: c.adv_lat || 0, adv_lon: c.adv_lon || 0, last_seen: c.last_advert || 0, on_device: false, source: c.source || 'cache', is_ignored: c.is_ignored || false, is_blocked: c.is_blocked || false, })); existingContacts = [...deviceContacts, ...cacheOnlyContacts]; filteredContacts = [...existingContacts]; // Update counter badge updateCounter(deviceData.count, deviceData.limit, cachedContacts.length); if (existingContacts.length === 0) { if (emptyEl) emptyEl.style.display = 'block'; } else { applySortAndFilters(); } } else { console.error('Failed to load existing contacts:', deviceData.error); if (errorEl) { const errorMsg = document.getElementById('existingErrorMessage'); if (errorMsg) errorMsg.textContent = deviceData.error || 'Failed to load contacts'; errorEl.style.display = 'block'; } } } catch (error) { console.error('Error loading existing contacts:', error); if (loadingEl) loadingEl.style.display = 'none'; if (errorEl) { const errorMsg = document.getElementById('existingErrorMessage'); if (errorMsg) errorMsg.textContent = 'Network error: ' + error.message; errorEl.style.display = 'block'; } } } function updateCounter(count, limit, totalKnown) { const counterEl = document.getElementById('contactsCounter'); if (!counterEl) return; let text = `${count} / ${limit}`; if (totalKnown && totalKnown > count) { text += ` (${totalKnown} cached)`; } counterEl.textContent = text; counterEl.style.display = 'inline-block'; // Remove all counter classes counterEl.classList.remove('counter-ok', 'counter-warning', 'counter-alarm'); // Apply appropriate class based on count if (count >= 340) { counterEl.classList.add('counter-alarm'); } else if (count >= 300) { counterEl.classList.add('counter-warning'); } else { counterEl.classList.add('counter-ok'); } } // ============================================================================= // Sorting Functionality (Existing Page) // ============================================================================= function parseSortParamsFromURL() { const urlParams = new URLSearchParams(window.location.search); sortBy = urlParams.get('sort') || 'last_advert'; sortOrder = urlParams.get('order') || 'desc'; console.log('Parsed sort params:', { sortBy, sortOrder }); // Update sort dropdown to reflect current sort const sortSelect = document.getElementById('sortSelect'); if (sortSelect) { sortSelect.value = `${sortBy}_${sortOrder}`; } } function updateURLWithSortParams() { const url = new URL(window.location); url.searchParams.set('sort', sortBy); url.searchParams.set('order', sortOrder); window.history.replaceState({}, '', url); } function applySortAndFilters() { const searchInput = document.getElementById('searchInput'); const typeFilter = document.getElementById('typeFilter'); const sourceFilter = document.getElementById('sourceFilter'); const searchTerm = searchInput ? searchInput.value.toLowerCase() : ''; const selectedType = typeFilter ? typeFilter.value : 'ALL'; const selectedSource = sourceFilter ? sourceFilter.value : 'ALL'; // First, filter contacts filteredContacts = existingContacts.filter(contact => { // Source filter if (selectedSource === 'DEVICE' && !contact.on_device) return false; if (selectedSource === 'CACHE' && contact.on_device) return false; if (selectedSource === 'IGNORED' && !contact.is_ignored) return false; if (selectedSource === 'BLOCKED' && !contact.is_blocked) return false; // Hide ignored/blocked from DEVICE/CACHE views (but show in ALL) if (selectedSource !== 'ALL' && selectedSource !== 'IGNORED' && selectedSource !== 'BLOCKED') { if (contact.is_ignored || contact.is_blocked) return false; } // Type filter (cache-only contacts have no type_label) if (selectedType !== 'ALL') { if (!contact.type_label || contact.type_label !== selectedType) { return false; } } // Search filter (name or public_key_prefix) if (searchTerm) { const nameMatch = contact.name.toLowerCase().includes(searchTerm); const keyMatch = (contact.public_key_prefix || '').toLowerCase().includes(searchTerm); return nameMatch || keyMatch; } return true; }); // Then, sort filtered contacts filteredContacts.sort((a, b) => { if (sortBy === 'name') { const comparison = a.name.localeCompare(b.name); return sortOrder === 'asc' ? comparison : -comparison; } else if (sortBy === 'last_advert') { const aTime = a.last_seen || 0; const bTime = b.last_seen || 0; return sortOrder === 'desc' ? bTime - aTime : aTime - bTime; } return 0; }); // Render sorted and filtered contacts renderExistingList(filteredContacts); // When Blocked filter is active, also show name-blocked entries if (selectedSource === 'BLOCKED') { loadBlockedNamesList(); } } function renderExistingList(contacts) { const listEl = document.getElementById('existingList'); const emptyEl = document.getElementById('existingEmpty'); if (!listEl) return; listEl.innerHTML = ''; if (contacts.length === 0) { if (emptyEl) emptyEl.style.display = 'block'; return; } if (emptyEl) emptyEl.style.display = 'none'; contacts.forEach((contact, index) => { const card = createExistingContactCard(contact, index); listEl.appendChild(card); }); } async function loadBlockedNamesList() { const listEl = document.getElementById('existingList'); if (!listEl) return; try { const response = await fetch('/api/contacts/blocked-names-list'); const data = await response.json(); if (!data.success || !data.blocked_names || data.blocked_names.length === 0) return; // Add a separator header const header = document.createElement('div'); header.className = 'text-muted small fw-bold mt-3 mb-2 px-1'; header.innerHTML = ' Blocked by name'; listEl.appendChild(header); data.blocked_names.forEach(entry => { const card = document.createElement('div'); card.className = 'existing-contact-card'; const infoRow = document.createElement('div'); infoRow.className = 'contact-info-row'; const nameDiv = document.createElement('div'); nameDiv.className = 'contact-name flex-grow-1'; nameDiv.textContent = entry.name; const statusIcon = document.createElement('span'); statusIcon.className = 'ms-1'; statusIcon.style.fontSize = '0.85rem'; statusIcon.innerHTML = ''; infoRow.appendChild(nameDiv); infoRow.appendChild(statusIcon); const actionsDiv = document.createElement('div'); actionsDiv.className = 'd-flex gap-2 mt-2'; const unblockBtn = document.createElement('button'); unblockBtn.className = 'btn btn-sm btn-outline-success'; unblockBtn.innerHTML = ' Unblock'; unblockBtn.onclick = async () => { try { const resp = await fetch('/api/contacts/block-name', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: entry.name, blocked: false }) }); const result = await resp.json(); if (result.success) { showToast(result.message, 'info'); loadExistingContacts(); } else { showToast('Failed: ' + result.error, 'danger'); } } catch (err) { showToast('Network error', 'danger'); } }; actionsDiv.appendChild(unblockBtn); card.appendChild(infoRow); card.appendChild(actionsDiv); listEl.appendChild(card); }); } catch (err) { console.error('Error loading blocked names:', err); } } /** * Format Unix timestamp as relative time ("5 minutes ago", "2 hours ago", etc.) */ function formatRelativeTime(timestamp) { if (!timestamp) return 'Never'; const now = Math.floor(Date.now() / 1000); // Current time in Unix seconds const diffSeconds = now - timestamp; if (diffSeconds < 0) return 'Just now'; // Future timestamp (clock skew) // Less than 1 minute if (diffSeconds < 60) { return 'Just now'; } // Less than 1 hour if (diffSeconds < 3600) { const minutes = Math.floor(diffSeconds / 60); return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`; } // Less than 1 day if (diffSeconds < 86400) { const hours = Math.floor(diffSeconds / 3600); return `${hours} hour${hours !== 1 ? 's' : ''} ago`; } // Less than 30 days if (diffSeconds < 2592000) { const days = Math.floor(diffSeconds / 86400); return `${days} day${days !== 1 ? 's' : ''} ago`; } // Less than 1 year if (diffSeconds < 31536000) { const months = Math.floor(diffSeconds / 2592000); return `${months} month${months !== 1 ? 's' : ''} ago`; } // More than 1 year const years = Math.floor(diffSeconds / 31536000); return `${years} year${years !== 1 ? 's' : ''} ago`; } /** * Get activity status indicator based on last_advert timestamp * Returns: { icon: string, color: string, title: string } */ function getActivityStatus(timestamp) { if (!timestamp) { return { icon: 'β«', color: '#6c757d', title: 'Never seen' }; } const now = Math.floor(Date.now() / 1000); const diffSeconds = now - timestamp; // Active (< 5 minutes) if (diffSeconds < 300) { return { icon: 'π’', color: '#28a745', title: 'Active (advert received recently)' }; } // Recent (< 1 hour) if (diffSeconds < 3600) { return { icon: 'π‘', color: '#ffc107', title: 'Recent activity' }; } // Inactive (> 1 hour) return { icon: 'π΄', color: '#dc3545', title: 'Inactive' }; } function createExistingContactCard(contact, index) { const card = document.createElement('div'); card.className = 'existing-contact-card'; card.id = `existing-contact-${index}`; // Check if contact is protected const isProtected = contact.is_protected || isContactProtected(contact.public_key); // Contact info row (name + type badge) const infoRow = document.createElement('div'); infoRow.className = 'contact-info-row'; const nameDiv = document.createElement('div'); nameDiv.className = 'contact-name flex-grow-1'; nameDiv.textContent = contact.name; // Add protection indicator if protected if (isProtected) { const lockIndicator = document.createElement('span'); lockIndicator.className = 'protection-indicator ms-2'; lockIndicator.innerHTML = ''; nameDiv.appendChild(lockIndicator); } // Type badge - use type_label if available, fall back to "Cache" for unknown type const typeBadge = document.createElement('span'); typeBadge.className = 'badge type-badge'; if (contact.type_label) { typeBadge.textContent = contact.type_label; switch (contact.type_label) { case 'COM': typeBadge.classList.add('bg-primary'); break; case 'REP': typeBadge.classList.add('bg-success'); break; case 'ROOM': typeBadge.classList.add('bg-info'); break; case 'SENS': typeBadge.classList.add('bg-warning'); break; default: typeBadge.classList.add('bg-secondary'); } } else { typeBadge.textContent = 'Cache'; typeBadge.classList.add('bg-secondary'); typeBadge.title = 'Not on device - type unknown'; } // Source icon (device vs cache) const sourceIcon = document.createElement('span'); sourceIcon.className = 'ms-1'; sourceIcon.style.fontSize = '0.85rem'; if (contact.on_device !== false) { sourceIcon.innerHTML = ''; } else { sourceIcon.innerHTML = ''; } // Status icon (ignored/blocked) let statusIcon = null; if (contact.is_blocked) { statusIcon = document.createElement('span'); statusIcon.className = 'ms-1'; statusIcon.style.fontSize = '0.85rem'; statusIcon.innerHTML = ''; } else if (contact.is_ignored) { statusIcon = document.createElement('span'); statusIcon.className = 'ms-1'; statusIcon.style.fontSize = '0.85rem'; statusIcon.innerHTML = ''; } infoRow.appendChild(nameDiv); infoRow.appendChild(typeBadge); infoRow.appendChild(sourceIcon); if (statusIcon) infoRow.appendChild(statusIcon); // Public key row (clickable to copy) const keyDiv = document.createElement('div'); keyDiv.className = 'contact-key clickable-key'; keyDiv.textContent = contact.public_key_prefix; keyDiv.title = 'Click to copy'; keyDiv.onclick = () => copyToClipboard(contact.public_key_prefix, keyDiv); // Last advert row (with activity status indicator) const lastAdvertDiv = document.createElement('div'); lastAdvertDiv.className = 'text-muted small d-flex align-items-center gap-1'; lastAdvertDiv.style.marginBottom = '0.25rem'; if (contact.last_seen) { const status = getActivityStatus(contact.last_seen); const relativeTime = formatRelativeTime(contact.last_seen); const statusIcon = document.createElement('span'); statusIcon.textContent = status.icon; statusIcon.style.fontSize = '0.9rem'; statusIcon.title = status.title; const timeText = document.createElement('span'); timeText.textContent = `Last advert: ${relativeTime}`; lastAdvertDiv.appendChild(statusIcon); lastAdvertDiv.appendChild(timeText); } else { // No last_seen data available const statusIcon = document.createElement('span'); statusIcon.textContent = 'β«'; statusIcon.style.fontSize = '0.9rem'; const timeText = document.createElement('span'); timeText.textContent = 'Last advert: Unknown'; lastAdvertDiv.appendChild(statusIcon); lastAdvertDiv.appendChild(timeText); } // Path/route info for device contacts let pathDiv = null; if (contact.on_device !== false) { pathDiv = document.createElement('div'); pathDiv.className = 'text-muted small'; const mode = contact.path_or_mode || 'Flood'; if (mode === 'Flood') { pathDiv.innerHTML = ' Flood'; } else if (mode === 'Direct') { pathDiv.innerHTML = ' Direct'; } else { // mode is formatted path like "E7βDEβ54β54βD8" const hopCount = mode.split('β').length; pathDiv.innerHTML = ` ${mode} (${hopCount} hops)`; } } // Action buttons const actionsDiv = document.createElement('div'); actionsDiv.className = 'd-flex gap-2 mt-2'; // Map button - for ANY contact with GPS coordinates if (contact.adv_lat && contact.adv_lon && (contact.adv_lat !== 0 || contact.adv_lon !== 0)) { const mapBtn = document.createElement('button'); mapBtn.className = 'btn btn-sm btn-outline-primary'; mapBtn.innerHTML = ' Map'; mapBtn.onclick = () => window.showContactOnMap(contact.name, contact.adv_lat, contact.adv_lon); actionsDiv.appendChild(mapBtn); } // Protect, Move to cache & Delete buttons (only for device contacts) if (contact.on_device !== false) { const protectBtn = document.createElement('button'); protectBtn.className = isProtected ? 'btn btn-sm btn-warning' : 'btn btn-sm btn-outline-warning'; protectBtn.innerHTML = isProtected ? ' Protected' : ' Protect'; protectBtn.onclick = () => toggleContactProtection(contact.public_key, protectBtn); actionsDiv.appendChild(protectBtn); const moveToCacheBtn = document.createElement('button'); moveToCacheBtn.className = 'btn btn-sm btn-outline-info'; moveToCacheBtn.innerHTML = ' To cache'; moveToCacheBtn.title = 'Remove from device, keep in cache'; moveToCacheBtn.onclick = () => moveContactToCache(contact); moveToCacheBtn.disabled = isProtected; if (isProtected) { moveToCacheBtn.title = 'Cannot move protected contact'; } actionsDiv.appendChild(moveToCacheBtn); const deleteBtn = document.createElement('button'); deleteBtn.className = 'btn btn-sm btn-outline-danger'; deleteBtn.innerHTML = ' Delete'; deleteBtn.onclick = () => showDeleteModal(contact); deleteBtn.disabled = isProtected; if (isProtected) { deleteBtn.title = 'Cannot delete protected contact'; } actionsDiv.appendChild(deleteBtn); } // Push to device & Delete buttons for cache-only contacts if (contact.on_device === false) { const pushToDeviceBtn = document.createElement('button'); pushToDeviceBtn.className = 'btn btn-sm btn-outline-success'; pushToDeviceBtn.innerHTML = ' To device'; pushToDeviceBtn.title = 'Add this contact to the device'; pushToDeviceBtn.onclick = () => pushContactToDevice(contact); actionsDiv.appendChild(pushToDeviceBtn); const deleteCacheBtn = document.createElement('button'); deleteCacheBtn.className = 'btn btn-sm btn-outline-danger'; deleteCacheBtn.innerHTML = ' Delete'; deleteCacheBtn.onclick = () => showDeleteModal(contact); actionsDiv.appendChild(deleteCacheBtn); } // Ignore/Block/Unignore/Unblock buttons if (contact.is_blocked) { const unblockBtn = document.createElement('button'); unblockBtn.className = 'btn btn-sm btn-outline-success'; unblockBtn.innerHTML = ' Unblock'; unblockBtn.onclick = () => toggleContactBlock(contact.public_key, false); actionsDiv.appendChild(unblockBtn); } else if (contact.is_ignored) { const unignoreBtn = document.createElement('button'); unignoreBtn.className = 'btn btn-sm btn-outline-success'; unignoreBtn.innerHTML = ' Unignore'; unignoreBtn.onclick = () => toggleContactIgnore(contact.public_key, false); actionsDiv.appendChild(unignoreBtn); } else { const ignoreBtn = document.createElement('button'); ignoreBtn.className = 'btn btn-sm btn-outline-secondary'; ignoreBtn.innerHTML = ' Ignore'; ignoreBtn.onclick = () => toggleContactIgnore(contact.public_key, true); if (isProtected) { ignoreBtn.disabled = true; ignoreBtn.title = 'Cannot ignore protected contact'; } actionsDiv.appendChild(ignoreBtn); const blockBtn = document.createElement('button'); blockBtn.className = 'btn btn-sm btn-outline-danger'; blockBtn.innerHTML = ' Block'; blockBtn.onclick = () => toggleContactBlock(contact.public_key, true); if (isProtected) { blockBtn.disabled = true; blockBtn.title = 'Cannot block protected contact'; } actionsDiv.appendChild(blockBtn); } // Assemble card card.appendChild(infoRow); card.appendChild(keyDiv); card.appendChild(lastAdvertDiv); if (pathDiv) card.appendChild(pathDiv); card.appendChild(actionsDiv); return card; } /** * Copy text to clipboard with fallback for HTTP contexts. * @param {string} text - Text to copy * @param {HTMLElement} element - Element for visual feedback */ function copyToClipboard(text, element) { const originalText = element.textContent; // Try modern clipboard API first (requires HTTPS) if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(() => { showCopyFeedback(element, originalText); }).catch(() => { // Fallback to legacy method legacyCopy(text, element, originalText); }); } else { // Fallback for HTTP contexts legacyCopy(text, element, originalText); } } /** * Legacy copy method using execCommand (works on HTTP). */ function legacyCopy(text, element, originalText) { const textArea = document.createElement('textarea'); textArea.value = text; textArea.style.position = 'fixed'; textArea.style.left = '-9999px'; document.body.appendChild(textArea); textArea.select(); try { document.execCommand('copy'); showCopyFeedback(element, originalText); } catch (err) { console.error('Failed to copy:', err); showToast('Failed to copy', 'danger'); } document.body.removeChild(textArea); } /** * Show visual feedback after successful copy. */ function showCopyFeedback(element, originalText) { element.textContent = 'Copied!'; element.classList.add('copied'); setTimeout(() => { element.textContent = originalText; element.classList.remove('copied'); }, 1500); showToast('Key copied to clipboard', 'info'); } function showDeleteModal(contact) { contactToDelete = contact; // Set modal content const modalNameEl = document.getElementById('deleteContactName'); const modalKeyEl = document.getElementById('deleteContactKey'); if (modalNameEl) modalNameEl.textContent = contact.name; if (modalKeyEl) modalKeyEl.textContent = contact.public_key_prefix; // Show modal const modal = new bootstrap.Modal(document.getElementById('deleteContactModal')); modal.show(); } async function confirmDelete() { if (!contactToDelete) return; const modal = bootstrap.Modal.getInstance(document.getElementById('deleteContactModal')); const confirmBtn = document.getElementById('confirmDeleteBtn'); // Disable button during deletion if (confirmBtn) { confirmBtn.disabled = true; confirmBtn.innerHTML = ' Deleting...'; } try { // Use different endpoint for cache-only vs device contacts const isCacheOnly = contactToDelete.on_device === false; const url = isCacheOnly ? '/api/contacts/cached/delete' : '/api/contacts/delete'; const body = isCacheOnly ? { public_key: contactToDelete.public_key } : { selector: contactToDelete.public_key }; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await response.json(); if (data.success) { showToast(`Deleted: ${contactToDelete.name}`, 'success'); // Hide modal if (modal) modal.hide(); // Reload contacts list setTimeout(() => loadExistingContacts(), 500); } else { console.error('Failed to delete contact:', data.error); showToast('Failed to delete: ' + data.error, 'danger'); } } catch (error) { console.error('Error deleting contact:', error); showToast('Network error: ' + error.message, 'danger'); } finally { // Re-enable button if (confirmBtn) { confirmBtn.disabled = false; confirmBtn.innerHTML = ' Delete Contact'; } contactToDelete = null; } } // ============================================================================= // Push to Device / Move to Cache // ============================================================================= async function pushContactToDevice(contact) { if (!confirm(`Push "${contact.name}" to device?`)) return; try { const response = await fetch(`/api/contacts/${contact.public_key}/push-to-device`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }); const data = await response.json(); if (data.success) { showToast(data.message || `${contact.name} pushed to device`, 'success'); setTimeout(() => loadExistingContacts(), 500); } else { showToast(data.error || 'Failed to push contact', 'danger'); } } catch (error) { showToast('Network error: ' + error.message, 'danger'); } } async function moveContactToCache(contact) { if (!confirm(`Move "${contact.name}" from device to cache?`)) return; try { const response = await fetch(`/api/contacts/${contact.public_key}/move-to-cache`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }); const data = await response.json(); if (data.success) { showToast(data.message || `${contact.name} moved to cache`, 'success'); setTimeout(() => loadExistingContacts(), 500); } else { showToast(data.error || 'Failed to move contact', 'danger'); } } catch (error) { showToast('Network error: ' + error.message, 'danger'); } } // ============================================================================= // Add Contact Page // ============================================================================= const TYPE_LABELS = {1: 'COM', 2: 'REP', 3: 'ROOM', 4: 'SENS'}; let html5QrCode = null; let qrScannedUri = null; function initAddPage() { console.log('Initializing Add Contact page...'); // URI tab listeners const uriInput = document.getElementById('uriInput'); uriInput.addEventListener('input', handleUriInput); document.getElementById('addFromUriBtn').addEventListener('click', () => submitContact('uri')); // QR tab listeners document.getElementById('startCameraBtn').addEventListener('click', startQrCamera); document.getElementById('stopCameraBtn').addEventListener('click', stopQrCamera); document.getElementById('qrFileInput').addEventListener('change', handleQrFile); document.getElementById('addFromQrBtn').addEventListener('click', () => submitContact('qr')); // Manual tab listeners const manualKey = document.getElementById('manualKey'); const manualName = document.getElementById('manualName'); manualKey.addEventListener('input', handleManualKeyInput); manualName.addEventListener('input', validateManualForm); document.getElementById('addManualBtn').addEventListener('click', () => submitContact('manual')); // Stop camera when switching away from QR tab document.getElementById('tab-qr').addEventListener('hidden.bs.tab', stopQrCamera); } /** * Parse a meshcore:// mobile app URI client-side for preview. * Returns {name, public_key, type} or null. */ function parseMeshcoreUri(uri) { if (!uri || !uri.startsWith('meshcore://')) return null; try { const url = new URL(uri); if (url.hostname !== 'contact' || url.pathname !== '/add') return null; const name = url.searchParams.get('name'); const publicKey = url.searchParams.get('public_key'); if (!name || !publicKey) return null; const key = publicKey.trim().toLowerCase(); if (key.length !== 64 || !/^[0-9a-f]{64}$/.test(key)) return null; let type = parseInt(url.searchParams.get('type') || '1', 10); if (![1,2,3,4].includes(type)) type = 1; return { name: name.trim(), public_key: key, type }; } catch { return null; } } // --- URI Tab --- function handleUriInput() { const uri = document.getElementById('uriInput').value.trim(); const preview = document.getElementById('uriPreview'); const btn = document.getElementById('addFromUriBtn'); // Try mobile app format first const parsed = parseMeshcoreUri(uri); if (parsed) { document.getElementById('uriPreviewName').textContent = parsed.name; document.getElementById('uriPreviewKey').textContent = parsed.public_key; document.getElementById('uriPreviewType').textContent = TYPE_LABELS[parsed.type] || 'COM'; preview.classList.remove('d-none'); btn.disabled = false; return; } // Hex blob format β can't preview but still valid if (uri.startsWith('meshcore://') && uri.length > 20) { preview.classList.add('d-none'); btn.disabled = false; return; } preview.classList.add('d-none'); btn.disabled = true; } // --- QR Tab --- function startQrCamera() { const readerEl = document.getElementById('qrReader'); if (!readerEl) return; html5QrCode = new Html5Qrcode('qrReader'); html5QrCode.start( { facingMode: 'environment' }, { fps: 10, qrbox: { width: 250, height: 250 } }, onQrCodeSuccess, () => {} // ignore scan failures ).then(() => { document.getElementById('startCameraBtn').classList.add('d-none'); document.getElementById('stopCameraBtn').classList.remove('d-none'); }).catch(err => { showQrError('Camera access denied or not available. Try uploading an image instead.'); console.error('QR camera error:', err); }); } function stopQrCamera() { if (html5QrCode && html5QrCode.isScanning) { html5QrCode.stop().catch(() => {}); } document.getElementById('startCameraBtn').classList.remove('d-none'); document.getElementById('stopCameraBtn').classList.add('d-none'); } function handleQrFile(event) { const file = event.target.files[0]; if (!file) return; const scanner = new Html5Qrcode('qrReader'); scanner.scanFile(file, true) .then(decodedText => { onQrCodeSuccess(decodedText); scanner.clear(); }) .catch(err => { showQrError('Could not read QR code from image. Make sure the image contains a valid QR code.'); console.error('QR file scan error:', err); }); } function onQrCodeSuccess(decodedText) { const resultDiv = document.getElementById('qrResult'); const errorDiv = document.getElementById('qrError'); const addBtn = document.getElementById('addFromQrBtn'); errorDiv.classList.add('d-none'); const parsed = parseMeshcoreUri(decodedText); if (parsed) { document.getElementById('qrResultName').textContent = parsed.name; document.getElementById('qrResultKey').textContent = parsed.public_key; document.getElementById('qrResultType').textContent = TYPE_LABELS[parsed.type] || 'COM'; resultDiv.classList.remove('d-none'); addBtn.classList.remove('d-none'); qrScannedUri = decodedText; stopQrCamera(); return; } // Hex blob format if (decodedText.startsWith('meshcore://') && decodedText.length > 20) { resultDiv.innerHTML = 'Scanned: ' + decodedText.substring(0, 60) + '...'; resultDiv.classList.remove('d-none'); addBtn.classList.remove('d-none'); qrScannedUri = decodedText; stopQrCamera(); return; } showQrError('QR code does not contain a valid meshcore:// URI.'); } function showQrError(msg) { const errorDiv = document.getElementById('qrError'); errorDiv.textContent = msg; errorDiv.classList.remove('d-none'); document.getElementById('qrResult').classList.add('d-none'); document.getElementById('addFromQrBtn').classList.add('d-none'); } // --- Manual Tab --- function handleManualKeyInput() { const input = document.getElementById('manualKey'); // Allow only hex characters input.value = input.value.replace(/[^0-9a-fA-F]/g, '').toLowerCase(); document.getElementById('manualKeyCount').textContent = `${input.value.length} / 64 characters`; validateManualForm(); } function validateManualForm() { const name = document.getElementById('manualName').value.trim(); const key = document.getElementById('manualKey').value.trim(); const btn = document.getElementById('addManualBtn'); btn.disabled = !(name.length > 0 && key.length === 64 && /^[0-9a-f]{64}$/.test(key)); } // --- Submit --- async function submitContact(mode) { const statusDiv = document.getElementById('addStatus'); let body = {}; if (mode === 'uri') { body.uri = document.getElementById('uriInput').value.trim(); } else if (mode === 'qr') { body.uri = qrScannedUri; } else if (mode === 'manual') { body.name = document.getElementById('manualName').value.trim(); body.public_key = document.getElementById('manualKey').value.trim(); body.type = parseInt(document.getElementById('manualType').value, 10); } // Show loading statusDiv.className = 'mt-3 alert alert-info'; statusDiv.innerHTML = 'Adding contact...'; statusDiv.classList.remove('d-none'); try { const response = await fetch('/api/contacts/manual-add', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await response.json(); if (data.success) { statusDiv.className = 'mt-3 alert alert-success'; statusDiv.textContent = data.message || 'Contact added successfully!'; // Reset form resetAddForm(mode); } else { statusDiv.className = 'mt-3 alert alert-danger'; statusDiv.textContent = data.error || 'Failed to add contact.'; } } catch (error) { statusDiv.className = 'mt-3 alert alert-danger'; statusDiv.textContent = 'Network error: ' + error.message; } } function resetAddForm(mode) { if (mode === 'uri') { document.getElementById('uriInput').value = ''; document.getElementById('uriPreview').classList.add('d-none'); document.getElementById('addFromUriBtn').disabled = true; } else if (mode === 'qr') { qrScannedUri = null; document.getElementById('qrResult').classList.add('d-none'); document.getElementById('addFromQrBtn').classList.add('d-none'); document.getElementById('qrFileInput').value = ''; } else if (mode === 'manual') { document.getElementById('manualName').value = ''; document.getElementById('manualKey').value = ''; document.getElementById('manualKeyCount').textContent = '0 / 64 characters'; document.getElementById('addManualBtn').disabled = true; } }