mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-03-28 17:42:45 +01:00
feat(contacts): add ignored and blocked contact lists
- New DB tables: ignored_contacts, blocked_contacts (keyed by pubkey) - Ignored contacts: cached but excluded from pending/auto-add - Blocked contacts: ignored + messages hidden from chat (stored in DB) - Backend: filter in _on_new_contact, _on_channel_message, _on_dm_received - API: /contacts/<pk>/ignore, /contacts/<pk>/block toggle endpoints - API: filter blocked from /api/messages and /dm/conversations - Frontend: Ignore/Block buttons on pending cards, existing cards, chat messages - Frontend: source filter dropdown with Ignored/Blocked options - Frontend: status icons (eye-slash, slash-circle) on contact cards - Frontend: real-time blocked message filtering via socketio - Name→pubkey mapping for chat window block/ignore buttons Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -186,6 +186,74 @@ class Database:
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
# ================================================================
|
||||
# Ignored / Blocked Contacts
|
||||
# ================================================================
|
||||
|
||||
def set_contact_ignored(self, public_key: str, ignored: bool) -> bool:
|
||||
pk = public_key.lower()
|
||||
with self._connect() as conn:
|
||||
if ignored:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO ignored_contacts (public_key) VALUES (?)", (pk,))
|
||||
# Remove from blocked if setting ignored
|
||||
conn.execute(
|
||||
"DELETE FROM blocked_contacts WHERE public_key = ?", (pk,))
|
||||
else:
|
||||
conn.execute(
|
||||
"DELETE FROM ignored_contacts WHERE public_key = ?", (pk,))
|
||||
return True
|
||||
|
||||
def set_contact_blocked(self, public_key: str, blocked: bool) -> bool:
|
||||
pk = public_key.lower()
|
||||
with self._connect() as conn:
|
||||
if blocked:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO blocked_contacts (public_key) VALUES (?)", (pk,))
|
||||
# Remove from ignored if setting blocked
|
||||
conn.execute(
|
||||
"DELETE FROM ignored_contacts WHERE public_key = ?", (pk,))
|
||||
else:
|
||||
conn.execute(
|
||||
"DELETE FROM blocked_contacts WHERE public_key = ?", (pk,))
|
||||
return True
|
||||
|
||||
def is_contact_ignored(self, public_key: str) -> bool:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM ignored_contacts WHERE public_key = ?",
|
||||
(public_key.lower(),)
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
def is_contact_blocked(self, public_key: str) -> bool:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM blocked_contacts WHERE public_key = ?",
|
||||
(public_key.lower(),)
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
def get_ignored_keys(self) -> set:
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute("SELECT public_key FROM ignored_contacts").fetchall()
|
||||
return {r['public_key'] for r in rows}
|
||||
|
||||
def get_blocked_keys(self) -> set:
|
||||
with self._connect() as conn:
|
||||
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)."""
|
||||
with self._connect() as conn:
|
||||
rows = 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}
|
||||
|
||||
# ================================================================
|
||||
# Channels
|
||||
# ================================================================
|
||||
|
||||
@@ -357,6 +357,10 @@ class DeviceManager:
|
||||
sender = 'Unknown'
|
||||
content = raw_text
|
||||
|
||||
# Check if sender is blocked (store but don't emit)
|
||||
blocked_names = self.db.get_blocked_contact_names()
|
||||
is_blocked = sender in blocked_names
|
||||
|
||||
msg_id = self.db.insert_channel_message(
|
||||
channel_idx=channel_idx,
|
||||
sender=sender,
|
||||
@@ -371,6 +375,10 @@ class DeviceManager:
|
||||
|
||||
logger.info(f"Channel msg #{msg_id} from {sender} on ch{channel_idx}")
|
||||
|
||||
if is_blocked:
|
||||
logger.debug(f"Blocked channel msg from {sender}, stored but not emitted")
|
||||
return
|
||||
|
||||
if self.socketio:
|
||||
self.socketio.emit('new_message', {
|
||||
'type': 'channel',
|
||||
@@ -421,6 +429,9 @@ class DeviceManager:
|
||||
logger.info(f"DM dedup: skipping retry from {sender_key[:8]}...")
|
||||
return
|
||||
|
||||
# Check if sender is blocked
|
||||
is_blocked = sender_key and self.db.is_contact_blocked(sender_key)
|
||||
|
||||
if sender_key:
|
||||
# Only upsert with name if we have a real name (not just a prefix)
|
||||
self.db.upsert_contact(
|
||||
@@ -443,6 +454,10 @@ class DeviceManager:
|
||||
|
||||
logger.info(f"DM #{dm_id} from {sender_name or sender_key[:12]}")
|
||||
|
||||
if is_blocked:
|
||||
logger.debug(f"Blocked DM from {sender_key[:12]}, stored but not emitted")
|
||||
return
|
||||
|
||||
if self.socketio:
|
||||
self.socketio.emit('new_message', {
|
||||
'type': 'dm',
|
||||
@@ -755,6 +770,26 @@ class DeviceManager:
|
||||
if not pubkey:
|
||||
return
|
||||
|
||||
# Ignored/blocked: still update cache but don't add to pending or device
|
||||
if self.db.is_contact_ignored(pubkey) or self.db.is_contact_blocked(pubkey):
|
||||
last_adv = data.get('last_advert')
|
||||
last_advert_val = (
|
||||
str(int(last_adv))
|
||||
if last_adv and isinstance(last_adv, (int, float)) and last_adv > 0
|
||||
else str(int(time.time()))
|
||||
)
|
||||
self.db.upsert_contact(
|
||||
public_key=pubkey,
|
||||
name=name,
|
||||
type=data.get('type', data.get('adv_type', 0)),
|
||||
adv_lat=data.get('adv_lat'),
|
||||
adv_lon=data.get('adv_lon'),
|
||||
last_advert=last_advert_val,
|
||||
source='advert',
|
||||
)
|
||||
logger.info(f"Ignored/blocked contact advert: {name} ({pubkey[:8]}...)")
|
||||
return
|
||||
|
||||
if self._is_manual_approval_enabled():
|
||||
# Manual mode: meshcore puts it in mc.pending_contacts for approval
|
||||
logger.info(f"Pending contact (manual mode): {name} ({pubkey[:8]}...)")
|
||||
|
||||
@@ -483,6 +483,11 @@ def get_messages():
|
||||
msg['echo_snrs'] = [e.get('snr') for e in echoes if e.get('snr') is not None]
|
||||
|
||||
messages.append(msg)
|
||||
|
||||
# Filter out blocked contacts' messages
|
||||
blocked_names = db.get_blocked_contact_names()
|
||||
if blocked_names:
|
||||
messages = [m for m in messages if m.get('sender', '') not in blocked_names]
|
||||
else:
|
||||
# Fallback to parser for file-based reads
|
||||
messages = parser.read_messages(
|
||||
@@ -672,6 +677,8 @@ def get_cached_contacts():
|
||||
if db:
|
||||
db_contacts = db.get_contacts()
|
||||
if fmt == 'full':
|
||||
ignored_keys = db.get_ignored_keys()
|
||||
blocked_keys = db.get_blocked_keys()
|
||||
contacts = []
|
||||
for c in db_contacts:
|
||||
pk = c.get('public_key', '')
|
||||
@@ -693,6 +700,8 @@ def get_cached_contacts():
|
||||
'adv_lat': c.get('adv_lat'),
|
||||
'adv_lon': c.get('adv_lon'),
|
||||
'type_label': {0: 'CLI', 1: 'CLI', 2: 'REP', 3: 'ROOM', 4: 'SENS'}.get(c.get('type', 1), 'UNKNOWN'),
|
||||
'is_ignored': pk in ignored_keys,
|
||||
'is_blocked': pk in blocked_keys,
|
||||
})
|
||||
return jsonify({
|
||||
'success': True,
|
||||
@@ -1761,6 +1770,9 @@ def get_dm_conversations():
|
||||
db = _get_db()
|
||||
if db:
|
||||
convos = db.get_dm_conversations()
|
||||
# Filter out blocked contacts
|
||||
blocked_keys = db.get_blocked_keys()
|
||||
convos = [c for c in convos if c.get('contact_pubkey', '').lower() not in blocked_keys]
|
||||
# Convert to frontend-compatible format
|
||||
conversations = []
|
||||
for c in convos:
|
||||
@@ -2182,8 +2194,11 @@ def get_contacts_detailed_api():
|
||||
type_labels = {1: 'CLI', 2: 'REP', 3: 'ROOM', 4: 'SENS'}
|
||||
contacts = []
|
||||
|
||||
# Get protected contacts for is_protected field
|
||||
# Get protected/ignored/blocked contacts for status fields
|
||||
protected_contacts = get_protected_contacts()
|
||||
db = _get_db()
|
||||
ignored_keys = db.get_ignored_keys() if db else set()
|
||||
blocked_keys = db.get_blocked_keys() if db else set()
|
||||
|
||||
for public_key, details in contacts_detailed.items():
|
||||
# Compute path display string
|
||||
@@ -2214,6 +2229,8 @@ def get_contacts_detailed_api():
|
||||
'path_or_mode': path_or_mode, # For UI display
|
||||
'last_seen': details.get('last_advert'), # Alias for compatibility
|
||||
'is_protected': public_key.lower() in protected_contacts, # Protection status
|
||||
'is_ignored': public_key.lower() in ignored_keys,
|
||||
'is_blocked': public_key.lower() in blocked_keys,
|
||||
}
|
||||
contacts.append(contact)
|
||||
|
||||
@@ -2422,6 +2439,114 @@ def toggle_contact_protection(public_key):
|
||||
}), 500
|
||||
|
||||
|
||||
@api_bp.route('/contacts/<public_key>/ignore', methods=['POST'])
|
||||
def toggle_contact_ignore(public_key):
|
||||
"""Toggle ignore status for a contact."""
|
||||
try:
|
||||
if not public_key or not re.match(r'^[a-fA-F0-9]{12,64}$', public_key):
|
||||
return jsonify({'success': False, 'error': 'Invalid public_key format'}), 400
|
||||
|
||||
public_key = public_key.lower()
|
||||
|
||||
# Resolve prefix to full key via DB
|
||||
db = _get_db()
|
||||
if len(public_key) < 64 and db:
|
||||
contact = db.get_contact_by_prefix(public_key)
|
||||
if contact:
|
||||
public_key = contact['public_key']
|
||||
else:
|
||||
return jsonify({'success': False, 'error': 'Contact not found'}), 404
|
||||
|
||||
data = request.get_json() or {}
|
||||
if 'ignored' in data:
|
||||
should_ignore = data['ignored']
|
||||
else:
|
||||
should_ignore = not db.is_contact_ignored(public_key)
|
||||
|
||||
# If ignoring a device contact, delete from device first
|
||||
if should_ignore:
|
||||
contact = db.get_contact(public_key)
|
||||
if contact and contact.get('source') == 'device':
|
||||
dm = _get_dm()
|
||||
if dm:
|
||||
dm.delete_contact(public_key)
|
||||
|
||||
db.set_contact_ignored(public_key, should_ignore)
|
||||
invalidate_contacts_cache()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'public_key': public_key,
|
||||
'ignored': should_ignore,
|
||||
'message': 'Contact ignored' if should_ignore else 'Contact unignored'
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error toggling contact ignore: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/contacts/<public_key>/block', methods=['POST'])
|
||||
def toggle_contact_block(public_key):
|
||||
"""Toggle block status for a contact."""
|
||||
try:
|
||||
if not public_key or not re.match(r'^[a-fA-F0-9]{12,64}$', public_key):
|
||||
return jsonify({'success': False, 'error': 'Invalid public_key format'}), 400
|
||||
|
||||
public_key = public_key.lower()
|
||||
|
||||
db = _get_db()
|
||||
if len(public_key) < 64 and db:
|
||||
contact = db.get_contact_by_prefix(public_key)
|
||||
if contact:
|
||||
public_key = contact['public_key']
|
||||
else:
|
||||
return jsonify({'success': False, 'error': 'Contact not found'}), 404
|
||||
|
||||
data = request.get_json() or {}
|
||||
if 'blocked' in data:
|
||||
should_block = data['blocked']
|
||||
else:
|
||||
should_block = not db.is_contact_blocked(public_key)
|
||||
|
||||
# If blocking a device contact, delete from device first
|
||||
if should_block:
|
||||
contact = db.get_contact(public_key)
|
||||
if contact and contact.get('source') == 'device':
|
||||
dm = _get_dm()
|
||||
if dm:
|
||||
dm.delete_contact(public_key)
|
||||
|
||||
db.set_contact_blocked(public_key, should_block)
|
||||
invalidate_contacts_cache()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'public_key': public_key,
|
||||
'blocked': should_block,
|
||||
'message': 'Contact blocked' if should_block else 'Contact unblocked'
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error toggling contact block: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/contacts/blocked-names', methods=['GET'])
|
||||
def get_blocked_contact_names_api():
|
||||
"""Get list of current names of blocked contacts (for frontend filtering)."""
|
||||
try:
|
||||
db = _get_db()
|
||||
if db:
|
||||
names = list(db.get_blocked_contact_names())
|
||||
else:
|
||||
names = []
|
||||
return jsonify({'success': True, 'names': names}), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting blocked contact names: {e}")
|
||||
return jsonify({'success': False, 'error': str(e), 'names': []}), 500
|
||||
|
||||
|
||||
@api_bp.route('/contacts/cleanup-settings', methods=['GET'])
|
||||
def get_cleanup_settings_api():
|
||||
"""
|
||||
@@ -2618,6 +2743,14 @@ def get_pending_contacts_api():
|
||||
success, pending, error = cli.get_pending_contacts()
|
||||
|
||||
if success:
|
||||
# Filter out ignored/blocked contacts from pending list
|
||||
db = _get_db()
|
||||
if db:
|
||||
ignored_keys = db.get_ignored_keys()
|
||||
blocked_keys = db.get_blocked_keys()
|
||||
excluded = ignored_keys | blocked_keys
|
||||
pending = [c for c in pending if c.get('public_key', '').lower() not in excluded]
|
||||
|
||||
# Filter by types if specified
|
||||
if types_param:
|
||||
pending = [contact for contact in pending if contact.get('type') in types_param]
|
||||
|
||||
@@ -137,6 +137,18 @@ CREATE TABLE IF NOT EXISTS read_status (
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Ignored contacts (adverts cached but not pending/auto-added)
|
||||
CREATE TABLE IF NOT EXISTS ignored_contacts (
|
||||
public_key TEXT PRIMARY KEY REFERENCES contacts(public_key),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Blocked contacts (ignored + messages hidden from display)
|
||||
CREATE TABLE IF NOT EXISTS blocked_contacts (
|
||||
public_key TEXT PRIMARY KEY REFERENCES contacts(public_key),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- Indexes
|
||||
-- ============================================================
|
||||
|
||||
@@ -21,6 +21,8 @@ let dmUnreadCounts = {}; // Track unread DM counts per conversation
|
||||
let leafletMap = null;
|
||||
let markersGroup = null;
|
||||
let contactsGeoCache = {}; // { 'contactName': { lat, lon }, ... }
|
||||
let contactsPubkeyMap = {}; // { 'contactName': 'full_pubkey', ... }
|
||||
let blockedContactNames = new Set(); // Names of blocked contacts
|
||||
let allContactsWithGps = []; // Cached contacts for map filtering
|
||||
|
||||
// SocketIO state
|
||||
@@ -239,23 +241,59 @@ async function showAllContactsOnMap() {
|
||||
*/
|
||||
async function loadContactsGeoCache() {
|
||||
try {
|
||||
const response = await fetch('/api/contacts/detailed');
|
||||
const data = await response.json();
|
||||
// Load detailed (device) and cached contacts in parallel
|
||||
const [detailedResp, cachedResp] = await Promise.all([
|
||||
fetch('/api/contacts/detailed'),
|
||||
fetch('/api/contacts/cached?format=full')
|
||||
]);
|
||||
const detailedData = await detailedResp.json();
|
||||
const cachedData = await cachedResp.json();
|
||||
|
||||
if (data.success && data.contacts) {
|
||||
contactsGeoCache = {};
|
||||
data.contacts.forEach(c => {
|
||||
contactsGeoCache = {};
|
||||
contactsPubkeyMap = {};
|
||||
|
||||
// Process device contacts
|
||||
if (detailedData.success && detailedData.contacts) {
|
||||
detailedData.contacts.forEach(c => {
|
||||
if (c.adv_lat && c.adv_lon && (c.adv_lat !== 0 || c.adv_lon !== 0)) {
|
||||
contactsGeoCache[c.name] = { lat: c.adv_lat, lon: c.adv_lon };
|
||||
}
|
||||
if (c.name && c.public_key) {
|
||||
contactsPubkeyMap[c.name] = c.public_key;
|
||||
}
|
||||
});
|
||||
console.log(`Loaded geo cache for ${Object.keys(contactsGeoCache).length} contacts`);
|
||||
}
|
||||
|
||||
// Process cached contacts (fills gaps for contacts not on device)
|
||||
if (cachedData.success && cachedData.contacts) {
|
||||
cachedData.contacts.forEach(c => {
|
||||
if (!contactsGeoCache[c.name] && c.adv_lat && c.adv_lon && (c.adv_lat !== 0 || c.adv_lon !== 0)) {
|
||||
contactsGeoCache[c.name] = { lat: c.adv_lat, lon: c.adv_lon };
|
||||
}
|
||||
if (c.name && c.public_key && !contactsPubkeyMap[c.name]) {
|
||||
contactsPubkeyMap[c.name] = c.public_key;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Loaded geo cache for ${Object.keys(contactsGeoCache).length} contacts, pubkey map for ${Object.keys(contactsPubkeyMap).length}`);
|
||||
} catch (err) {
|
||||
console.error('Error loading contacts geo cache:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBlockedNames() {
|
||||
try {
|
||||
const resp = await fetch('/api/contacts/blocked-names');
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
blockedContactNames = new Set(data.names);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading blocked names:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
/**
|
||||
* Connect to SocketIO /chat namespace for real-time message updates
|
||||
@@ -280,6 +318,10 @@ function connectChatSocket() {
|
||||
|
||||
// Real-time new channel message
|
||||
chatSocket.on('new_message', (data) => {
|
||||
// Filter blocked contacts in real-time
|
||||
if (data.type === 'channel' && blockedContactNames.has(data.sender)) return;
|
||||
if (data.type === 'dm' && blockedContactNames.has(data.sender)) return;
|
||||
|
||||
if (data.type === 'channel') {
|
||||
// Update unread count for this channel
|
||||
if (data.channel_idx !== currentChannelIdx) {
|
||||
@@ -347,6 +389,7 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
// Start these in parallel - messages are critical, geo cache can load async
|
||||
const messagesPromise = loadMessages();
|
||||
const geoCachePromise = loadContactsGeoCache(); // Non-blocking, Map buttons update when ready
|
||||
const blockedPromise = loadBlockedNames(); // Non-blocking, for real-time filtering
|
||||
|
||||
// Also start archive list loading in parallel
|
||||
loadArchiveList();
|
||||
@@ -868,6 +911,14 @@ function createMessageElement(msg) {
|
||||
<i class="bi bi-clipboard-data"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
${contactsPubkeyMap[msg.sender] ? `
|
||||
<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>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -980,6 +1031,45 @@ function resendMessage(content) {
|
||||
input.focus();
|
||||
}
|
||||
|
||||
async function ignoreContactFromChat(pubkey) {
|
||||
try {
|
||||
const response = await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/ignore`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ignored: true })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showToast(data.message, 'info');
|
||||
} else {
|
||||
showToast('Failed: ' + data.error, 'danger');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Network error', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function blockContactFromChat(pubkey, senderName) {
|
||||
if (!confirm(`Block ${senderName || 'this contact'}? 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 data = await response.json();
|
||||
if (data.success) {
|
||||
showToast(data.message, 'warning');
|
||||
await loadBlockedNames();
|
||||
loadMessages(); // re-render to hide blocked messages
|
||||
} else {
|
||||
showToast('Failed: ' + data.error, 'danger');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Network error', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show paths popup on tap (mobile-friendly, shows all routes)
|
||||
*/
|
||||
|
||||
@@ -1069,6 +1069,49 @@ function updateProtectionUI(publicKey, isProtected, buttonEl) {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleContactIgnore(publicKey, ignored) {
|
||||
try {
|
||||
const response = await fetch(`/api/contacts/${encodeURIComponent(publicKey)}/ignore`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ignored })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showToast(data.message, 'info');
|
||||
loadExistingContacts();
|
||||
loadContactCounts();
|
||||
} else {
|
||||
showToast('Failed: ' + data.error, 'danger');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling ignore:', error);
|
||||
showToast('Network error', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleContactBlock(publicKey, blocked) {
|
||||
if (blocked && !confirm('Block this contact? Their messages will be hidden from chat.')) return;
|
||||
try {
|
||||
const response = await fetch(`/api/contacts/${encodeURIComponent(publicKey)}/block`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ blocked })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showToast(data.message, blocked ? 'warning' : 'info');
|
||||
loadExistingContacts();
|
||||
loadContactCounts();
|
||||
} else {
|
||||
showToast('Failed: ' + data.error, 'danger');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling block:', error);
|
||||
showToast('Network error', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a contact is protected.
|
||||
* @param {string} publicKey - Public key to check
|
||||
@@ -1338,6 +1381,24 @@ function createContactCard(contact, index) {
|
||||
|
||||
actionsDiv.appendChild(copyBtn);
|
||||
|
||||
// Ignore button
|
||||
const ignoreBtn = document.createElement('button');
|
||||
ignoreBtn.className = 'btn btn-sm btn-outline-secondary';
|
||||
ignoreBtn.innerHTML = '<i class="bi bi-eye-slash"></i> Ignore';
|
||||
ignoreBtn.onclick = () => {
|
||||
toggleContactIgnore(contact.public_key, true).then(() => loadPendingContacts());
|
||||
};
|
||||
actionsDiv.appendChild(ignoreBtn);
|
||||
|
||||
// Block button
|
||||
const blockBtn = document.createElement('button');
|
||||
blockBtn.className = 'btn btn-sm btn-outline-danger';
|
||||
blockBtn.innerHTML = '<i class="bi bi-slash-circle"></i> Block';
|
||||
blockBtn.onclick = () => {
|
||||
toggleContactBlock(contact.public_key, true).then(() => loadPendingContacts());
|
||||
};
|
||||
actionsDiv.appendChild(blockBtn);
|
||||
|
||||
// Assemble card
|
||||
card.appendChild(infoRow);
|
||||
card.appendChild(keyDiv);
|
||||
@@ -1656,7 +1717,9 @@ async function loadExistingContacts() {
|
||||
adv_lon: c.adv_lon || 0,
|
||||
last_seen: c.last_advert || 0,
|
||||
on_device: false,
|
||||
source: c.source || 'cache'
|
||||
source: c.source || 'cache',
|
||||
is_ignored: c.is_ignored || false,
|
||||
is_blocked: c.is_blocked || false,
|
||||
}));
|
||||
|
||||
existingContacts = [...deviceContacts, ...cacheOnlyContacts];
|
||||
@@ -1752,6 +1815,12 @@ function applySortAndFilters() {
|
||||
// Source filter
|
||||
if (selectedSource === 'DEVICE' && !contact.on_device) return false;
|
||||
if (selectedSource === 'CACHE' && contact.on_device) return false;
|
||||
if (selectedSource === 'IGNORED' && !contact.is_ignored) return false;
|
||||
if (selectedSource === 'BLOCKED' && !contact.is_blocked) return false;
|
||||
// Hide ignored/blocked from ALL/DEVICE/CACHE views
|
||||
if (selectedSource !== 'IGNORED' && selectedSource !== 'BLOCKED') {
|
||||
if (contact.is_ignored || contact.is_blocked) return false;
|
||||
}
|
||||
|
||||
// Type filter (cache-only contacts have no type_label)
|
||||
if (selectedType !== 'ALL') {
|
||||
@@ -1948,9 +2017,24 @@ function createExistingContactCard(contact, index) {
|
||||
sourceIcon.innerHTML = '<i class="bi bi-cloud text-secondary" title="Cache only"></i>';
|
||||
}
|
||||
|
||||
// Status icon (ignored/blocked)
|
||||
let statusIcon = null;
|
||||
if (contact.is_blocked) {
|
||||
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"></i>';
|
||||
} else if (contact.is_ignored) {
|
||||
statusIcon = document.createElement('span');
|
||||
statusIcon.className = 'ms-1';
|
||||
statusIcon.style.fontSize = '0.85rem';
|
||||
statusIcon.innerHTML = '<i class="bi bi-eye-slash text-secondary" title="Ignored"></i>';
|
||||
}
|
||||
|
||||
infoRow.appendChild(nameDiv);
|
||||
infoRow.appendChild(typeBadge);
|
||||
infoRow.appendChild(sourceIcon);
|
||||
if (statusIcon) infoRow.appendChild(statusIcon);
|
||||
|
||||
// Public key row (clickable to copy)
|
||||
const keyDiv = document.createElement('div');
|
||||
@@ -2033,6 +2117,33 @@ function createExistingContactCard(contact, index) {
|
||||
actionsDiv.appendChild(deleteBtn);
|
||||
}
|
||||
|
||||
// Ignore/Block/Unignore/Unblock buttons
|
||||
if (contact.is_blocked) {
|
||||
const unblockBtn = document.createElement('button');
|
||||
unblockBtn.className = 'btn btn-sm btn-outline-success';
|
||||
unblockBtn.innerHTML = '<i class="bi bi-slash-circle"></i> Unblock';
|
||||
unblockBtn.onclick = () => toggleContactBlock(contact.public_key, false);
|
||||
actionsDiv.appendChild(unblockBtn);
|
||||
} else if (contact.is_ignored) {
|
||||
const unignoreBtn = document.createElement('button');
|
||||
unignoreBtn.className = 'btn btn-sm btn-outline-success';
|
||||
unignoreBtn.innerHTML = '<i class="bi bi-eye"></i> Unignore';
|
||||
unignoreBtn.onclick = () => toggleContactIgnore(contact.public_key, false);
|
||||
actionsDiv.appendChild(unignoreBtn);
|
||||
} else {
|
||||
const ignoreBtn = document.createElement('button');
|
||||
ignoreBtn.className = 'btn btn-sm btn-outline-secondary';
|
||||
ignoreBtn.innerHTML = '<i class="bi bi-eye-slash"></i> Ignore';
|
||||
ignoreBtn.onclick = () => toggleContactIgnore(contact.public_key, true);
|
||||
actionsDiv.appendChild(ignoreBtn);
|
||||
|
||||
const blockBtn = document.createElement('button');
|
||||
blockBtn.className = 'btn btn-sm btn-outline-danger';
|
||||
blockBtn.innerHTML = '<i class="bi bi-slash-circle"></i> Block';
|
||||
blockBtn.onclick = () => toggleContactBlock(contact.public_key, true);
|
||||
actionsDiv.appendChild(blockBtn);
|
||||
}
|
||||
|
||||
// Assemble card
|
||||
card.appendChild(infoRow);
|
||||
card.appendChild(keyDiv);
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
<option value="ALL">All sources</option>
|
||||
<option value="DEVICE">On device</option>
|
||||
<option value="CACHE">Cache only</option>
|
||||
<option value="IGNORED">Ignored</option>
|
||||
<option value="BLOCKED">Blocked</option>
|
||||
</select>
|
||||
|
||||
<!-- Type Filter -->
|
||||
|
||||
Reference in New Issue
Block a user