diff --git a/app/database.py b/app/database.py index 9945bc3..89b836a 100644 --- a/app/database.py +++ b/app/database.py @@ -244,15 +244,38 @@ class Database: 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).""" + def set_name_blocked(self, name: str, blocked: bool) -> bool: + with self._connect() as conn: + if blocked: + conn.execute( + "INSERT OR IGNORE INTO blocked_names (name) VALUES (?)", (name,)) + else: + conn.execute( + "DELETE FROM blocked_names WHERE name = ?", (name,)) + return True + + def get_blocked_names_list(self) -> list: + """Return list of directly blocked names (from blocked_names table).""" with self._connect() as conn: rows = conn.execute( + "SELECT name, created_at FROM blocked_names ORDER BY created_at DESC" + ).fetchall() + return [dict(r) for r in rows] + + def get_blocked_contact_names(self) -> set: + """Return all blocked names: from blocked_contacts (by pubkey) + blocked_names (by name).""" + with self._connect() as conn: + # Names from pubkey-blocked contacts + rows1 = 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} + # Names from directly blocked names + rows2 = conn.execute( + "SELECT name FROM blocked_names" + ).fetchall() + return {r['name'] for r in rows1} | {r['name'] for r in rows2} # ================================================================ # Channels diff --git a/app/routes/api.py b/app/routes/api.py index 5f8c9d3..43508b5 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -2547,6 +2547,49 @@ def get_blocked_contact_names_api(): return jsonify({'success': False, 'error': str(e), 'names': []}), 500 +@api_bp.route('/contacts/block-name', methods=['POST']) +def block_name_api(): + """Block/unblock a contact by name (for bots without known public_key).""" + try: + data = request.get_json() + if not data or 'name' not in data: + return jsonify({'success': False, 'error': 'Missing name parameter'}), 400 + + name = data['name'].strip() + if not name: + return jsonify({'success': False, 'error': 'Name cannot be empty'}), 400 + + blocked = data.get('blocked', True) + db = _get_db() + if db: + db.set_name_blocked(name, blocked) + return jsonify({ + 'success': True, + 'name': name, + 'blocked': blocked, + 'message': f'Name "{name}" blocked' if blocked else f'Name "{name}" unblocked' + }), 200 + return jsonify({'success': False, 'error': 'Database not available'}), 500 + except Exception as e: + logger.error(f"Error blocking name: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@api_bp.route('/contacts/blocked-names-list', methods=['GET']) +def get_blocked_names_list_api(): + """Get list of directly blocked names (from blocked_names table).""" + try: + db = _get_db() + if db: + entries = db.get_blocked_names_list() + else: + entries = [] + return jsonify({'success': True, 'blocked_names': entries}), 200 + except Exception as e: + logger.error(f"Error getting blocked names list: {e}") + return jsonify({'success': False, 'error': str(e), 'blocked_names': []}), 500 + + @api_bp.route('/contacts/cleanup-settings', methods=['GET']) def get_cleanup_settings_api(): """ diff --git a/app/schema.sql b/app/schema.sql index 404b8b7..076371b 100644 --- a/app/schema.sql +++ b/app/schema.sql @@ -149,6 +149,12 @@ CREATE TABLE IF NOT EXISTS blocked_contacts ( created_at TEXT NOT NULL DEFAULT (datetime('now')) ); +-- Blocked names (for bots/contacts without known public_key) +CREATE TABLE IF NOT EXISTS blocked_names ( + name TEXT PRIMARY KEY, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + -- ============================================================ -- Indexes -- ============================================================ diff --git a/app/static/css/style.css b/app/static/css/style.css index 81a92d8..1c09f0a 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -333,18 +333,6 @@ main { font-size: 0.875rem; } - /* Contact card buttons: icon-only on mobile */ - .pending-contact-card .btn-label, - .existing-contact-card .btn-label { - display: none; - } - - /* Allow button wrapping on small screens */ - .pending-contact-card .d-flex.gap-2, - .existing-contact-card .d-flex.gap-2 { - flex-wrap: wrap; - } - /* Modal: Better mobile layout */ .modal-dialog { margin: 0.5rem; @@ -360,6 +348,19 @@ main { } } +/* Small phone screens: icon-only buttons in contact cards */ +@media (max-width: 428px) { + .pending-contact-card .btn-label, + .existing-contact-card .btn-label { + display: none; + } + + .pending-contact-card .d-flex.gap-2, + .existing-contact-card .d-flex.gap-2 { + flex-wrap: wrap; + } +} + /* Loading State */ .loading-spinner { display: flex; diff --git a/app/static/js/app.js b/app/static/js/app.js index 566b34e..44a1fbc 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -915,10 +915,10 @@ function createMessageElement(msg) { - ` : ''} + @@ -1049,14 +1049,26 @@ async function ignoreContactFromChat(pubkey) { } } -async function blockContactFromChat(pubkey, senderName) { - if (!confirm(`Block ${senderName || 'this contact'}? Their messages will be hidden from chat.`)) return; +async function blockContactFromChat(senderName) { + if (!confirm(`Block ${senderName}? 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 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) { showToast(data.message, 'warning'); diff --git a/app/static/js/contacts.js b/app/static/js/contacts.js index 0ab3dea..0e29d9c 100644 --- a/app/static/js/contacts.js +++ b/app/static/js/contacts.js @@ -1847,6 +1847,13 @@ function applySortAndFilters() { // Render sorted and filtered contacts renderExistingList(filteredContacts); + + // When Blocked filter is active, also show name-blocked entries + const sourceFilter = document.getElementById('sourceFilter'); + const selectedSource = sourceFilter ? sourceFilter.value : 'ALL'; + if (selectedSource === 'BLOCKED') { + loadBlockedNamesList(); + } } function renderExistingList(contacts) { @@ -1870,6 +1877,75 @@ function renderExistingList(contacts) { }); } +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.) */