feat: contacts settings tab with suppress + auto-ignore options

Move Manual approval toggle into a new Contacts tab in the global
Settings modal and clean up the Contact Management panel (drop the
duplicated Settings/Manage Contacts headers, shorten the Existing
Contacts blurb). Add two new persisted options gated on Manual
approval being ON: Suppress new advert notifications (frontend hides
FAB badge + browser notification while the Pending list itself stays
populated) and Automatically add new contacts to "Ignored" (advert
handler marks the new contact ignored before emitting pending_contact,
so the user is silenced end-to-end while contacts remain in the cache
for promotion via "To Device").

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-04-18 10:01:58 +02:00
parent 0a08759065
commit 3dd1c52687
6 changed files with 251 additions and 116 deletions
+17
View File
@@ -1156,6 +1156,14 @@ class DeviceManager:
pass
return False
def _is_auto_ignore_new_adverts_enabled(self) -> bool:
"""Check if new adverts should be auto-marked as Ignored (from database)."""
try:
return bool(self.db.get_setting_json('auto_ignore_new_adverts', False))
except Exception:
pass
return False
async def _on_new_contact(self, event):
"""Handle new contact discovered.
@@ -1247,6 +1255,15 @@ class DeviceManager:
source='advert', # cache-only until approved
)
# Auto-ignore: mark as ignored so it never shows up as pending
if self._is_auto_ignore_new_adverts_enabled():
try:
self.db.set_contact_ignored(pubkey, True)
logger.info(f"Auto-ignored new advert: {name} ({pubkey[:8]}...)")
except Exception as e:
logger.warning(f"Failed to auto-ignore {pubkey[:8]}: {e}")
return # no socket emit -> no badge -> no notification
if self.socketio:
self.socketio.emit('pending_contact', {
'public_key': pubkey,
+79
View File
@@ -3753,6 +3753,85 @@ def update_device_settings_api():
}), 500
@api_bp.route('/contacts/settings', methods=['GET'])
def get_contacts_settings_api():
"""Get contact-management UI/behaviour settings.
Returns:
{
"success": true,
"settings": {
"manual_add_contacts": bool,
"suppress_advert_notifications": bool,
"auto_ignore_new_adverts": bool
}
}
"""
try:
success, dev_settings = cli.get_device_settings()
manual = bool(dev_settings.get('manual_add_contacts', False)) if success else False
db = _get_db()
suppress = bool(db.get_setting_json('suppress_advert_notifications', False)) if db else False
auto_ignore = bool(db.get_setting_json('auto_ignore_new_adverts', False)) if db else False
return jsonify({
'success': True,
'settings': {
'manual_add_contacts': manual,
'suppress_advert_notifications': suppress,
'auto_ignore_new_adverts': auto_ignore,
}
}), 200
except Exception as e:
logger.error(f"Error getting contacts settings: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@api_bp.route('/contacts/settings', methods=['POST'])
def update_contacts_settings_api():
"""Update one or more contact-management settings (partial payload allowed).
JSON body keys (all optional):
manual_add_contacts: bool -> applied to device + persisted
suppress_advert_notifications: bool -> persisted (frontend-only behaviour)
auto_ignore_new_adverts: bool -> persisted, used by device_manager
"""
try:
data = request.get_json() or {}
if not isinstance(data, dict):
return jsonify({'success': False, 'error': 'Invalid payload'}), 400
db = _get_db()
if 'manual_add_contacts' in data:
val = data['manual_add_contacts']
if not isinstance(val, bool):
return jsonify({'success': False, 'error': 'manual_add_contacts must be a boolean'}), 400
success, message = cli.set_manual_add_contacts(val)
if not success:
return jsonify({'success': False, 'error': message}), 500
if 'suppress_advert_notifications' in data:
val = data['suppress_advert_notifications']
if not isinstance(val, bool):
return jsonify({'success': False, 'error': 'suppress_advert_notifications must be a boolean'}), 400
if db:
db.set_setting_json('suppress_advert_notifications', val)
if 'auto_ignore_new_adverts' in data:
val = data['auto_ignore_new_adverts']
if not isinstance(val, bool):
return jsonify({'success': False, 'error': 'auto_ignore_new_adverts must be a boolean'}), 400
if db:
db.set_setting_json('auto_ignore_new_adverts', val)
return jsonify({'success': True}), 200
except Exception as e:
logger.error(f"Error updating contacts settings: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
# =============================================================================
# Message Retention Settings
# =============================================================================
+112 -3
View File
@@ -451,8 +451,9 @@ function connectChatSocket() {
}, 2000);
});
// Real-time pending contact — update badge
// Real-time pending contact — update badge (unless suppressed by user setting)
chatSocket.on('pending_contact', () => {
if (window.contactsSettings?.suppress_advert_notifications) return;
updatePendingContactsBadge();
});
@@ -2325,6 +2326,7 @@ document.addEventListener('DOMContentLoaded', () => {
loadDmRetrySettings();
loadChatSettings();
loadUiSettings();
loadContactsSettings();
});
settingsModal.addEventListener('shown.bs.modal', () => {
settingsModal.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
@@ -2333,6 +2335,20 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
// Contacts tab toggle handlers
document.getElementById('settManualApproval')?.addEventListener('change', (e) => {
saveContactsSetting('manual_add_contacts', e.target.checked, e.target);
});
document.getElementById('settSuppressAdvertNotifs')?.addEventListener('change', (e) => {
saveContactsSetting('suppress_advert_notifications', e.target.checked, e.target);
});
document.getElementById('settAutoIgnoreAdverts')?.addEventListener('change', (e) => {
saveContactsSetting('auto_ignore_new_adverts', e.target.checked, e.target);
});
// Initial load so suppress flag is available before user opens Settings
loadContactsSettings();
const dmRetryForm = document.getElementById('dmRetrySettingsForm');
if (dmRetryForm) {
dmRetryForm.addEventListener('submit', (e) => {
@@ -2606,6 +2622,90 @@ async function handleNotificationToggle() {
}
}
// =============================================================================
// Contacts Settings (Settings modal → Contacts tab)
// =============================================================================
window.contactsSettings = {
manual_add_contacts: false,
suppress_advert_notifications: false,
auto_ignore_new_adverts: false,
};
async function loadContactsSettings() {
try {
const resp = await fetch('/api/contacts/settings');
if (!resp.ok) return;
const data = await resp.json();
if (!data.success) return;
const s = data.settings || {};
window.contactsSettings = {
manual_add_contacts: !!s.manual_add_contacts,
suppress_advert_notifications: !!s.suppress_advert_notifications,
auto_ignore_new_adverts: !!s.auto_ignore_new_adverts,
};
const m = document.getElementById('settManualApproval');
const s1 = document.getElementById('settSuppressAdvertNotifs');
const s2 = document.getElementById('settAutoIgnoreAdverts');
if (m) m.checked = window.contactsSettings.manual_add_contacts;
if (s1) s1.checked = window.contactsSettings.suppress_advert_notifications;
if (s2) s2.checked = window.contactsSettings.auto_ignore_new_adverts;
applyContactsSettingsEnableState(window.contactsSettings.manual_add_contacts);
// If suppress was just turned on while page open, clear the FAB badge now
if (window.contactsSettings.suppress_advert_notifications) {
updateFabBadge('.fab-contacts', 'fab-badge-pending', 0);
}
} catch (e) {
console.error('Error loading contacts settings:', e);
}
}
function applyContactsSettingsEnableState(manualOn) {
const s1 = document.getElementById('settSuppressAdvertNotifs');
const s2 = document.getElementById('settAutoIgnoreAdverts');
const l1 = document.getElementById('settSuppressAdvertNotifsLabel');
const l2 = document.getElementById('settAutoIgnoreAdvertsLabel');
[s1, s2].forEach(el => {
if (!el) return;
el.disabled = !manualOn;
});
[l1, l2].forEach(el => {
if (!el) return;
el.classList.toggle('text-muted', !manualOn);
});
}
async function saveContactsSetting(key, value, inputEl) {
try {
const resp = await fetch('/api/contacts/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [key]: value }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.success) {
if (inputEl) inputEl.checked = !value;
showNotification(data.error || 'Failed to save setting', 'danger');
return;
}
window.contactsSettings[key] = !!value;
if (key === 'manual_add_contacts') {
applyContactsSettingsEnableState(!!value);
}
if (key === 'suppress_advert_notifications' && value) {
updateFabBadge('.fab-contacts', 'fab-badge-pending', 0);
}
if (key === 'suppress_advert_notifications' && !value) {
// Re-fetch real count when re-enabling notifications
updatePendingContactsBadge();
}
} catch (e) {
console.error('Error saving contacts setting:', e);
if (inputEl) inputEl.checked = !value;
showNotification('Network error saving setting', 'danger');
}
}
/**
* Send browser notification when new messages arrive
* @param {number} channelCount - Number of channels with new messages
@@ -2679,9 +2779,11 @@ function checkAndNotify() {
const dmBadge = document.querySelector('.fab-badge-dm');
const currentDmUnread = dmBadge ? parseInt(dmBadge.textContent) || 0 : 0;
// Get pending contacts count from badge
// Get pending contacts count from badge (forced to 0 when notifications are suppressed)
const pendingBadge = document.querySelector('.fab-badge-pending');
const currentPendingCount = pendingBadge ? parseInt(pendingBadge.textContent) || 0 : 0;
const rawPendingCount = pendingBadge ? parseInt(pendingBadge.textContent) || 0 : 0;
const currentPendingCount = window.contactsSettings?.suppress_advert_notifications
? 0 : rawPendingCount;
// Detect increases (new messages/contacts)
const channelIncrease = currentTotalUnread > previousTotalUnread;
@@ -4033,6 +4135,13 @@ function updateDmBadges(totalUnread) {
*/
async function updatePendingContactsBadge() {
try {
// Suppress: hide FAB badge entirely, skip browser notification path
if (window.contactsSettings?.suppress_advert_notifications) {
updateFabBadge('.fab-contacts', 'fab-badge-pending', 0);
updateAppBadge();
return;
}
// Load type filter from localStorage (uses same function as contacts.js)
const savedTypes = loadPendingTypeFilter();
-86
View File
@@ -77,7 +77,6 @@ window.navigateTo = function(url) {
// =============================================================================
let currentPage = null; // 'manage', 'pending', 'existing'
let manualApprovalEnabled = false;
let pendingContacts = [];
let filteredPendingContacts = []; // Filtered pending contacts (for pending page filtering)
let existingContacts = [];
@@ -210,9 +209,6 @@ function initializePage() {
function initManagePage() {
console.log('Initializing Management page...');
// Load settings for manual approval toggle
loadSettings();
// Load contact counts for badges
loadContactCounts();
@@ -224,12 +220,6 @@ function initManagePage() {
}
function attachManageEventListeners() {
// Manual approval toggle
const approvalSwitch = document.getElementById('manualApprovalSwitch');
if (approvalSwitch) {
approvalSwitch.addEventListener('change', handleApprovalToggle);
}
// Cleanup preview button
const cleanupPreviewBtn = document.getElementById('cleanupPreviewBtn');
if (cleanupPreviewBtn) {
@@ -945,82 +935,6 @@ function attachExistingEventListeners() {
}
}
// =============================================================================
// Settings Management (shared)
// =============================================================================
async function loadSettings() {
try {
const response = await fetch('/api/device/settings');
const data = await response.json();
if (data.success) {
manualApprovalEnabled = data.settings.manual_add_contacts || false;
updateApprovalUI(manualApprovalEnabled);
} else {
console.error('Failed to load settings:', data.error);
showToast('Failed to load settings', 'danger');
}
} catch (error) {
console.error('Error loading settings:', error);
showToast('Network error loading settings', 'danger');
}
}
async function handleApprovalToggle(event) {
const enabled = event.target.checked;
try {
const response = await fetch('/api/device/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
manual_add_contacts: enabled
})
});
const data = await response.json();
if (data.success) {
manualApprovalEnabled = enabled;
updateApprovalUI(enabled);
showToast(
enabled ? 'Manual approval enabled' : 'Manual approval disabled',
'success'
);
} else {
console.error('Failed to update setting:', data.error);
showToast('Failed to update setting: ' + data.error, 'danger');
// Revert toggle on failure
event.target.checked = !enabled;
}
} catch (error) {
console.error('Error updating setting:', error);
showToast('Network error updating setting', 'danger');
// Revert toggle on failure
event.target.checked = !enabled;
}
}
function updateApprovalUI(enabled) {
const switchEl = document.getElementById('manualApprovalSwitch');
const labelEl = document.getElementById('switchLabel');
if (switchEl) {
switchEl.checked = enabled;
}
if (labelEl) {
labelEl.textContent = enabled
? 'Manual approval enabled'
: 'Automatic approval (default)';
}
}
// =============================================================================
// Protected Contacts Management
// =============================================================================
+42
View File
@@ -385,6 +385,9 @@
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsAppearance" type="button">Appearance</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsContacts" type="button">Contacts</button>
</li>
</ul>
<div class="tab-content">
<!-- Device Settings Tab -->
@@ -679,6 +682,45 @@
</tbody>
</table>
</div>
<div class="tab-pane fade" id="tabSettingsContacts">
<table class="table table-sm table-borderless mb-0 align-middle">
<tbody>
<tr>
<td class="ps-0">Manual approval enabled
<span class="badge rounded-pill text-muted" data-bs-toggle="tooltip"
title="When enabled, new contacts must be manually approved before they can communicate with your node"><i class="bi bi-info-circle"></i></span>
</td>
<td class="pe-0">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="settManualApproval">
</div>
</td>
</tr>
<tr>
<td class="ps-0" id="settSuppressAdvertNotifsLabel">Suppress new advert notifications
<span class="badge rounded-pill text-muted" data-bs-toggle="tooltip"
title="Hide the badge over Contact Management and the browser notification when new pending contacts arrive. The Pending Contacts list itself still shows them. Requires Manual approval ON."><i class="bi bi-info-circle"></i></span>
</td>
<td class="pe-0">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="settSuppressAdvertNotifs">
</div>
</td>
</tr>
<tr>
<td class="ps-0" id="settAutoIgnoreAdvertsLabel">Automatically add new contacts to "Ignored"
<span class="badge rounded-pill text-muted" data-bs-toggle="tooltip"
title="Every new advert is automatically marked as Ignored — no notifications, no badge. Contacts still appear under Existing Contacts (Cache) and can be promoted with 'To Device'. Requires Manual approval ON."><i class="bi bi-info-circle"></i></span>
</td>
<td class="pe-0">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="settAutoIgnoreAdverts">
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
+1 -27
View File
@@ -4,34 +4,8 @@
{% block page_content %}
<div id="managePageContent" class="p-3">
<!-- Page Header -->
<div class="mb-4">
<h2 class="mb-1">
<i class="bi bi-gear"></i> Settings
</h2>
<p class="text-muted small mb-0">Configure contact management preferences</p>
</div>
<!-- Manual Approval Settings Section -->
<div class="compact-setting">
<div class="form-check form-switch mb-0 d-flex align-items-center gap-2">
<input class="form-check-input" type="checkbox" role="switch" id="manualApprovalSwitch" style="cursor: pointer; min-width: 3rem; min-height: 1.5rem;">
<label class="form-check-label mb-0" for="manualApprovalSwitch" style="cursor: pointer; font-weight: 500;">
<span id="switchLabel">Loading...</span>
</label>
</div>
<i class="bi bi-info-circle info-icon"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="When enabled, new contacts must be manually approved before they can communicate with your node"></i>
</div>
<!-- Navigation Section -->
<div class="mb-4">
<h5 class="mb-3">
<i class="bi bi-list-ul"></i> Manage Contacts
</h5>
<!-- Add Contact Card -->
<div class="nav-card" onclick="navigateTo('/contacts/add');" style="border-left: 4px solid #198754;">
<div>
@@ -56,7 +30,7 @@
<div class="nav-card" onclick="navigateTo('/contacts/existing');">
<div>
<h6><i class="bi bi-person-lines-fill"></i> Existing Contacts</h6>
<small class="text-muted">View and manage your approved contacts</small>
<small class="text-muted">Manage all stored contacts</small>
</div>
<span class="badge counter-badge counter-ok rounded-pill" id="existingBadge" style="font-size: 1.1rem;">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>