mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-05-07 05:44:43 +02:00
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:
+26
-3
@@ -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
|
||||
|
||||
@@ -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():
|
||||
"""
|
||||
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
|
||||
@@ -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.)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user