feat(contacts): name-based blocking, fix CSS breakpoint

- New blocked_names table for blocking bots without known public_key
- get_blocked_contact_names() returns union of pubkey-blocked + name-blocked
- POST /api/contacts/block-name endpoint for name-based blocking
- GET /api/contacts/blocked-names-list for management UI
- Block button always visible in chat (falls back to name-based block)
- Blocked Names section shown in Existing Contacts Blocked filter
- CSS breakpoint for icon-only buttons: 768px → 428px (iPhone-sized)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-03-10 21:03:19 +01:00
parent 0d5c021e40
commit b0076c3739
6 changed files with 186 additions and 25 deletions
+26 -3
View File
@@ -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
+43
View File
@@ -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():
"""
+6
View File
@@ -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
-- ============================================================
+13 -12
View File
@@ -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;
+22 -10
View File
@@ -915,10 +915,10 @@ function createMessageElement(msg) {
<button class="btn btn-outline-secondary btn-msg-action" onclick="ignoreContactFromChat('${contactsPubkeyMap[msg.sender]}')" title="Ignore ${escapeHtml(msg.sender)}">
<i class="bi bi-eye-slash"></i>
</button>
<button class="btn btn-outline-danger btn-msg-action" onclick="blockContactFromChat('${contactsPubkeyMap[msg.sender]}', '${escapeHtml(msg.sender)}')" title="Block ${escapeHtml(msg.sender)}">
<i class="bi bi-slash-circle"></i>
</button>
` : ''}
<button class="btn btn-outline-danger btn-msg-action" onclick="blockContactFromChat('${escapeHtml(msg.sender)}')" title="Block ${escapeHtml(msg.sender)}">
<i class="bi bi-slash-circle"></i>
</button>
</div>
</div>
</div>
@@ -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');
+76
View File
@@ -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 = '<i class="bi bi-slash-circle"></i> 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 = '<i class="bi bi-slash-circle text-danger" title="Blocked by name"></i>';
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 = '<i class="bi bi-slash-circle"></i> <span class="btn-label">Unblock</span>';
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.)
*/