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:
MarekWo
2026-03-09 21:10:21 +01:00
parent b709cc7b14
commit 2a3a48ed5f
7 changed files with 459 additions and 8 deletions

View File

@@ -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
# ================================================================

View File

@@ -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]}...)")

View File

@@ -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]

View File

@@ -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
-- ============================================================

View File

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

View File

@@ -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);

View File

@@ -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 -->