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.)
*/