From 2a3a48ed5fdb48a5853a71e7495f3444b5e26251 Mon Sep 17 00:00:00 2001 From: MarekWo Date: Mon, 9 Mar 2026 21:10:21 +0100 Subject: [PATCH] feat(contacts): add ignored and blocked contact lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New DB tables: ignored_contacts, blocked_contacts (keyed by pubkey) - Ignored contacts: cached but excluded from pending/auto-add - Blocked contacts: ignored + messages hidden from chat (stored in DB) - Backend: filter in _on_new_contact, _on_channel_message, _on_dm_received - API: /contacts//ignore, /contacts//block toggle endpoints - API: filter blocked from /api/messages and /dm/conversations - Frontend: Ignore/Block buttons on pending cards, existing cards, chat messages - Frontend: source filter dropdown with Ignored/Blocked options - Frontend: status icons (eye-slash, slash-circle) on contact cards - Frontend: real-time blocked message filtering via socketio - Name→pubkey mapping for chat window block/ignore buttons Co-Authored-By: Claude Opus 4.6 --- app/database.py | 68 ++++++++++++++ app/device_manager.py | 35 +++++++ app/routes/api.py | 135 ++++++++++++++++++++++++++- app/schema.sql | 12 +++ app/static/js/app.js | 102 ++++++++++++++++++-- app/static/js/contacts.js | 113 +++++++++++++++++++++- app/templates/contacts-existing.html | 2 + 7 files changed, 459 insertions(+), 8 deletions(-) diff --git a/app/database.py b/app/database.py index ee827c0..9945bc3 100644 --- a/app/database.py +++ b/app/database.py @@ -186,6 +186,74 @@ class Database: ) return cursor.rowcount > 0 + # ================================================================ + # Ignored / Blocked Contacts + # ================================================================ + + def set_contact_ignored(self, public_key: str, ignored: bool) -> bool: + pk = public_key.lower() + with self._connect() as conn: + if ignored: + conn.execute( + "INSERT OR IGNORE INTO ignored_contacts (public_key) VALUES (?)", (pk,)) + # Remove from blocked if setting ignored + conn.execute( + "DELETE FROM blocked_contacts WHERE public_key = ?", (pk,)) + else: + conn.execute( + "DELETE FROM ignored_contacts WHERE public_key = ?", (pk,)) + return True + + def set_contact_blocked(self, public_key: str, blocked: bool) -> bool: + pk = public_key.lower() + with self._connect() as conn: + if blocked: + conn.execute( + "INSERT OR IGNORE INTO blocked_contacts (public_key) VALUES (?)", (pk,)) + # Remove from ignored if setting blocked + conn.execute( + "DELETE FROM ignored_contacts WHERE public_key = ?", (pk,)) + else: + conn.execute( + "DELETE FROM blocked_contacts WHERE public_key = ?", (pk,)) + return True + + def is_contact_ignored(self, public_key: str) -> bool: + with self._connect() as conn: + row = conn.execute( + "SELECT 1 FROM ignored_contacts WHERE public_key = ?", + (public_key.lower(),) + ).fetchone() + return row is not None + + def is_contact_blocked(self, public_key: str) -> bool: + with self._connect() as conn: + row = conn.execute( + "SELECT 1 FROM blocked_contacts WHERE public_key = ?", + (public_key.lower(),) + ).fetchone() + return row is not None + + def get_ignored_keys(self) -> set: + with self._connect() as conn: + rows = conn.execute("SELECT public_key FROM ignored_contacts").fetchall() + return {r['public_key'] for r in rows} + + def get_blocked_keys(self) -> set: + with self._connect() as conn: + rows = conn.execute("SELECT public_key FROM blocked_contacts").fetchall() + return {r['public_key'] for r in rows} + + def get_blocked_contact_names(self) -> set: + """Return current names of blocked contacts (for channel msg filtering).""" + with self._connect() as conn: + rows = conn.execute( + """SELECT c.name FROM blocked_contacts bc + JOIN contacts c ON bc.public_key = c.public_key + WHERE c.name != ''""" + ).fetchall() + return {r['name'] for r in rows} + # ================================================================ # Channels # ================================================================ diff --git a/app/device_manager.py b/app/device_manager.py index f995c32..b145559 100644 --- a/app/device_manager.py +++ b/app/device_manager.py @@ -357,6 +357,10 @@ class DeviceManager: sender = 'Unknown' content = raw_text + # Check if sender is blocked (store but don't emit) + blocked_names = self.db.get_blocked_contact_names() + is_blocked = sender in blocked_names + msg_id = self.db.insert_channel_message( channel_idx=channel_idx, sender=sender, @@ -371,6 +375,10 @@ class DeviceManager: logger.info(f"Channel msg #{msg_id} from {sender} on ch{channel_idx}") + if is_blocked: + logger.debug(f"Blocked channel msg from {sender}, stored but not emitted") + return + if self.socketio: self.socketio.emit('new_message', { 'type': 'channel', @@ -421,6 +429,9 @@ class DeviceManager: logger.info(f"DM dedup: skipping retry from {sender_key[:8]}...") return + # Check if sender is blocked + is_blocked = sender_key and self.db.is_contact_blocked(sender_key) + if sender_key: # Only upsert with name if we have a real name (not just a prefix) self.db.upsert_contact( @@ -443,6 +454,10 @@ class DeviceManager: logger.info(f"DM #{dm_id} from {sender_name or sender_key[:12]}") + if is_blocked: + logger.debug(f"Blocked DM from {sender_key[:12]}, stored but not emitted") + return + if self.socketio: self.socketio.emit('new_message', { 'type': 'dm', @@ -755,6 +770,26 @@ class DeviceManager: if not pubkey: return + # Ignored/blocked: still update cache but don't add to pending or device + if self.db.is_contact_ignored(pubkey) or self.db.is_contact_blocked(pubkey): + last_adv = data.get('last_advert') + last_advert_val = ( + str(int(last_adv)) + if last_adv and isinstance(last_adv, (int, float)) and last_adv > 0 + else str(int(time.time())) + ) + self.db.upsert_contact( + public_key=pubkey, + name=name, + type=data.get('type', data.get('adv_type', 0)), + adv_lat=data.get('adv_lat'), + adv_lon=data.get('adv_lon'), + last_advert=last_advert_val, + source='advert', + ) + logger.info(f"Ignored/blocked contact advert: {name} ({pubkey[:8]}...)") + return + if self._is_manual_approval_enabled(): # Manual mode: meshcore puts it in mc.pending_contacts for approval logger.info(f"Pending contact (manual mode): {name} ({pubkey[:8]}...)") diff --git a/app/routes/api.py b/app/routes/api.py index 6510a60..ee5f222 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -483,6 +483,11 @@ def get_messages(): msg['echo_snrs'] = [e.get('snr') for e in echoes if e.get('snr') is not None] messages.append(msg) + + # Filter out blocked contacts' messages + blocked_names = db.get_blocked_contact_names() + if blocked_names: + messages = [m for m in messages if m.get('sender', '') not in blocked_names] else: # Fallback to parser for file-based reads messages = parser.read_messages( @@ -672,6 +677,8 @@ def get_cached_contacts(): if db: db_contacts = db.get_contacts() if fmt == 'full': + ignored_keys = db.get_ignored_keys() + blocked_keys = db.get_blocked_keys() contacts = [] for c in db_contacts: pk = c.get('public_key', '') @@ -693,6 +700,8 @@ def get_cached_contacts(): 'adv_lat': c.get('adv_lat'), 'adv_lon': c.get('adv_lon'), 'type_label': {0: 'CLI', 1: 'CLI', 2: 'REP', 3: 'ROOM', 4: 'SENS'}.get(c.get('type', 1), 'UNKNOWN'), + 'is_ignored': pk in ignored_keys, + 'is_blocked': pk in blocked_keys, }) return jsonify({ 'success': True, @@ -1761,6 +1770,9 @@ def get_dm_conversations(): db = _get_db() if db: convos = db.get_dm_conversations() + # Filter out blocked contacts + blocked_keys = db.get_blocked_keys() + convos = [c for c in convos if c.get('contact_pubkey', '').lower() not in blocked_keys] # Convert to frontend-compatible format conversations = [] for c in convos: @@ -2182,8 +2194,11 @@ def get_contacts_detailed_api(): type_labels = {1: 'CLI', 2: 'REP', 3: 'ROOM', 4: 'SENS'} contacts = [] - # Get protected contacts for is_protected field + # Get protected/ignored/blocked contacts for status fields protected_contacts = get_protected_contacts() + db = _get_db() + ignored_keys = db.get_ignored_keys() if db else set() + blocked_keys = db.get_blocked_keys() if db else set() for public_key, details in contacts_detailed.items(): # Compute path display string @@ -2214,6 +2229,8 @@ def get_contacts_detailed_api(): 'path_or_mode': path_or_mode, # For UI display 'last_seen': details.get('last_advert'), # Alias for compatibility 'is_protected': public_key.lower() in protected_contacts, # Protection status + 'is_ignored': public_key.lower() in ignored_keys, + 'is_blocked': public_key.lower() in blocked_keys, } contacts.append(contact) @@ -2422,6 +2439,114 @@ def toggle_contact_protection(public_key): }), 500 +@api_bp.route('/contacts//ignore', methods=['POST']) +def toggle_contact_ignore(public_key): + """Toggle ignore status for a contact.""" + try: + if not public_key or not re.match(r'^[a-fA-F0-9]{12,64}$', public_key): + return jsonify({'success': False, 'error': 'Invalid public_key format'}), 400 + + public_key = public_key.lower() + + # Resolve prefix to full key via DB + db = _get_db() + if len(public_key) < 64 and db: + contact = db.get_contact_by_prefix(public_key) + if contact: + public_key = contact['public_key'] + else: + return jsonify({'success': False, 'error': 'Contact not found'}), 404 + + data = request.get_json() or {} + if 'ignored' in data: + should_ignore = data['ignored'] + else: + should_ignore = not db.is_contact_ignored(public_key) + + # If ignoring a device contact, delete from device first + if should_ignore: + contact = db.get_contact(public_key) + if contact and contact.get('source') == 'device': + dm = _get_dm() + if dm: + dm.delete_contact(public_key) + + db.set_contact_ignored(public_key, should_ignore) + invalidate_contacts_cache() + + return jsonify({ + 'success': True, + 'public_key': public_key, + 'ignored': should_ignore, + 'message': 'Contact ignored' if should_ignore else 'Contact unignored' + }), 200 + + except Exception as e: + logger.error(f"Error toggling contact ignore: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@api_bp.route('/contacts//block', methods=['POST']) +def toggle_contact_block(public_key): + """Toggle block status for a contact.""" + try: + if not public_key or not re.match(r'^[a-fA-F0-9]{12,64}$', public_key): + return jsonify({'success': False, 'error': 'Invalid public_key format'}), 400 + + public_key = public_key.lower() + + db = _get_db() + if len(public_key) < 64 and db: + contact = db.get_contact_by_prefix(public_key) + if contact: + public_key = contact['public_key'] + else: + return jsonify({'success': False, 'error': 'Contact not found'}), 404 + + data = request.get_json() or {} + if 'blocked' in data: + should_block = data['blocked'] + else: + should_block = not db.is_contact_blocked(public_key) + + # If blocking a device contact, delete from device first + if should_block: + contact = db.get_contact(public_key) + if contact and contact.get('source') == 'device': + dm = _get_dm() + if dm: + dm.delete_contact(public_key) + + db.set_contact_blocked(public_key, should_block) + invalidate_contacts_cache() + + return jsonify({ + 'success': True, + 'public_key': public_key, + 'blocked': should_block, + 'message': 'Contact blocked' if should_block else 'Contact unblocked' + }), 200 + + except Exception as e: + logger.error(f"Error toggling contact block: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@api_bp.route('/contacts/blocked-names', methods=['GET']) +def get_blocked_contact_names_api(): + """Get list of current names of blocked contacts (for frontend filtering).""" + try: + db = _get_db() + if db: + names = list(db.get_blocked_contact_names()) + else: + names = [] + return jsonify({'success': True, 'names': names}), 200 + except Exception as e: + logger.error(f"Error getting blocked contact names: {e}") + return jsonify({'success': False, 'error': str(e), 'names': []}), 500 + + @api_bp.route('/contacts/cleanup-settings', methods=['GET']) def get_cleanup_settings_api(): """ @@ -2618,6 +2743,14 @@ def get_pending_contacts_api(): success, pending, error = cli.get_pending_contacts() if success: + # Filter out ignored/blocked contacts from pending list + db = _get_db() + if db: + ignored_keys = db.get_ignored_keys() + blocked_keys = db.get_blocked_keys() + excluded = ignored_keys | blocked_keys + pending = [c for c in pending if c.get('public_key', '').lower() not in excluded] + # Filter by types if specified if types_param: pending = [contact for contact in pending if contact.get('type') in types_param] diff --git a/app/schema.sql b/app/schema.sql index 422d6a4..404b8b7 100644 --- a/app/schema.sql +++ b/app/schema.sql @@ -137,6 +137,18 @@ CREATE TABLE IF NOT EXISTS read_status ( updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); +-- Ignored contacts (adverts cached but not pending/auto-added) +CREATE TABLE IF NOT EXISTS ignored_contacts ( + public_key TEXT PRIMARY KEY REFERENCES contacts(public_key), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Blocked contacts (ignored + messages hidden from display) +CREATE TABLE IF NOT EXISTS blocked_contacts ( + public_key TEXT PRIMARY KEY REFERENCES contacts(public_key), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + -- ============================================================ -- Indexes -- ============================================================ diff --git a/app/static/js/app.js b/app/static/js/app.js index 8d55a77..566b34e 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -21,6 +21,8 @@ let dmUnreadCounts = {}; // Track unread DM counts per conversation 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 allContactsWithGps = []; // Cached contacts for map filtering // SocketIO state @@ -239,23 +241,59 @@ async function showAllContactsOnMap() { */ async function loadContactsGeoCache() { try { - const response = await fetch('/api/contacts/detailed'); - const data = await response.json(); + // 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(); - if (data.success && data.contacts) { - contactsGeoCache = {}; - data.contacts.forEach(c => { + 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; + } }); - console.log(`Loaded geo cache for ${Object.keys(contactsGeoCache).length} contacts`); } + + // 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); + } +} + // Initialize on page load /** * Connect to SocketIO /chat namespace for real-time message updates @@ -280,6 +318,10 @@ function connectChatSocket() { // 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 unread count for this channel if (data.channel_idx !== currentChannelIdx) { @@ -347,6 +389,7 @@ document.addEventListener('DOMContentLoaded', async function() { // 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 // Also start archive list loading in parallel loadArchiveList(); @@ -868,6 +911,14 @@ function createMessageElement(msg) { ` : ''} + ${contactsPubkeyMap[msg.sender] ? ` + + + ` : ''} @@ -980,6 +1031,45 @@ function resendMessage(content) { input.focus(); } +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) { + showToast(data.message, 'info'); + } else { + showToast('Failed: ' + data.error, 'danger'); + } + } catch (err) { + showToast('Network error', 'danger'); + } +} + +async function blockContactFromChat(pubkey, senderName) { + if (!confirm(`Block ${senderName || 'this contact'}? Their messages will be hidden from chat.`)) return; + try { + const response = await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/block`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ blocked: true }) + }); + const data = await response.json(); + if (data.success) { + showToast(data.message, 'warning'); + await loadBlockedNames(); + loadMessages(); // re-render to hide blocked messages + } else { + showToast('Failed: ' + data.error, 'danger'); + } + } catch (err) { + showToast('Network error', 'danger'); + } +} + /** * Show paths popup on tap (mobile-friendly, shows all routes) */ diff --git a/app/static/js/contacts.js b/app/static/js/contacts.js index 72ef708..5c65d94 100644 --- a/app/static/js/contacts.js +++ b/app/static/js/contacts.js @@ -1069,6 +1069,49 @@ function updateProtectionUI(publicKey, isProtected, buttonEl) { } } +async function toggleContactIgnore(publicKey, ignored) { + try { + const response = await fetch(`/api/contacts/${encodeURIComponent(publicKey)}/ignore`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ignored }) + }); + const data = await response.json(); + if (data.success) { + showToast(data.message, 'info'); + loadExistingContacts(); + loadContactCounts(); + } else { + showToast('Failed: ' + data.error, 'danger'); + } + } catch (error) { + console.error('Error toggling ignore:', error); + showToast('Network error', 'danger'); + } +} + +async function toggleContactBlock(publicKey, blocked) { + if (blocked && !confirm('Block this contact? Their messages will be hidden from chat.')) return; + try { + const response = await fetch(`/api/contacts/${encodeURIComponent(publicKey)}/block`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ blocked }) + }); + const data = await response.json(); + if (data.success) { + showToast(data.message, blocked ? 'warning' : 'info'); + loadExistingContacts(); + loadContactCounts(); + } else { + showToast('Failed: ' + data.error, 'danger'); + } + } catch (error) { + console.error('Error toggling block:', error); + showToast('Network error', 'danger'); + } +} + /** * Check if a contact is protected. * @param {string} publicKey - Public key to check @@ -1338,6 +1381,24 @@ function createContactCard(contact, index) { actionsDiv.appendChild(copyBtn); + // 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); @@ -1656,7 +1717,9 @@ async function loadExistingContacts() { adv_lon: c.adv_lon || 0, last_seen: c.last_advert || 0, on_device: false, - source: c.source || 'cache' + source: c.source || 'cache', + is_ignored: c.is_ignored || false, + is_blocked: c.is_blocked || false, })); existingContacts = [...deviceContacts, ...cacheOnlyContacts]; @@ -1752,6 +1815,12 @@ function applySortAndFilters() { // 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 ALL/DEVICE/CACHE views + if (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') { @@ -1948,9 +2017,24 @@ function createExistingContactCard(contact, index) { 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'); @@ -2033,6 +2117,33 @@ function createExistingContactCard(contact, index) { actionsDiv.appendChild(deleteBtn); } + // 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); + 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); + actionsDiv.appendChild(blockBtn); + } + // Assemble card card.appendChild(infoRow); card.appendChild(keyDiv); diff --git a/app/templates/contacts-existing.html b/app/templates/contacts-existing.html index eb17c77..7e88340 100644 --- a/app/templates/contacts-existing.html +++ b/app/templates/contacts-existing.html @@ -34,6 +34,8 @@ + +