diff --git a/app/routers/contacts.py b/app/routers/contacts.py index 3e628b8..fdf8f63 100644 --- a/app/routers/contacts.py +++ b/app/routers/contacts.py @@ -5,6 +5,7 @@ from contextlib import suppress from fastapi import APIRouter, BackgroundTasks, HTTPException, Query from meshcore import EventType +from pydantic import BaseModel, Field from app.dependencies import require_connected from app.models import ( @@ -347,6 +348,41 @@ async def mark_contact_read(public_key: str) -> dict: return {"status": "ok", "public_key": contact.public_key} +class BulkDeleteRequest(BaseModel): + public_keys: list[str] = Field(description="Public keys to delete") + + +@router.post("/bulk-delete") +async def bulk_delete_contacts(request: BulkDeleteRequest) -> dict: + """Delete multiple contacts from the database (and radio if present).""" + from app.websocket import broadcast_event + + deleted = 0 + for key in request.public_keys: + normalized = key.lower() + contact = await ContactRepository.get_by_key(normalized) + if not contact: + continue + + if radio_manager.is_connected: + try: + async with radio_manager.radio_operation( + "bulk_delete_contact_from_radio", blocking=False + ) as mc: + radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12]) + if radio_contact: + await mc.commands.remove_contact(radio_contact) + except Exception: + pass # Best-effort radio removal during bulk delete + + await ContactRepository.delete(contact.public_key) + broadcast_event("contact_deleted", {"public_key": contact.public_key}) + deleted += 1 + + logger.info("Bulk deleted %d/%d contacts", deleted, len(request.public_keys)) + return {"deleted": deleted} + + @router.delete("/{public_key}") async def delete_contact(public_key: str) -> dict: """Delete a contact from the database (and radio if present).""" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 84ac2ec..71f83c5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -557,6 +557,7 @@ export function App() { blockedNames: appSettings?.blocked_names, onToggleBlockedKey: handleBlockKey, onToggleBlockedName: handleBlockName, + contacts, }; const crackerProps = { packets: rawPackets, diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 8872cff..1cce13e 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -149,6 +149,12 @@ export const api = { fetchJson<{ status: string }>(`/contacts/${publicKey}`, { method: 'DELETE', }), + bulkDeleteContacts: (publicKeys: string[]) => + fetchJson<{ deleted: number }>('/contacts/bulk-delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ public_keys: publicKeys }), + }), createContact: (publicKey: string, name?: string, tryHistorical?: boolean) => fetchJson('/contacts', { method: 'POST', diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 8c47953..18f85af 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, type ReactNode } from 'react'; import type { AppSettings, AppSettingsUpdate, + Contact, HealthStatus, RadioAdvertMode, RadioConfig, @@ -47,6 +48,7 @@ interface SettingsModalBaseProps { blockedNames?: string[]; onToggleBlockedKey?: (key: string) => void; onToggleBlockedName?: (name: string) => void; + contacts?: Contact[]; } export type SettingsModalProps = SettingsModalBaseProps & @@ -80,6 +82,7 @@ export function SettingsModal(props: SettingsModalProps) { blockedNames, onToggleBlockedKey, onToggleBlockedName, + contacts, } = props; const externalSidebarNav = props.externalSidebarNav === true; const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined; @@ -239,6 +242,7 @@ export function SettingsModal(props: SettingsModalProps) { blockedNames={blockedNames} onToggleBlockedKey={onToggleBlockedKey} onToggleBlockedName={onToggleBlockedName} + contacts={contacts} className={sectionContentClass} /> ) : ( diff --git a/frontend/src/components/settings/BulkDeleteContactsModal.tsx b/frontend/src/components/settings/BulkDeleteContactsModal.tsx new file mode 100644 index 0000000..3410c17 --- /dev/null +++ b/frontend/src/components/settings/BulkDeleteContactsModal.tsx @@ -0,0 +1,341 @@ +import { useCallback, useMemo, useRef, useState } from 'react'; +import { api } from '../../api'; +import { getContactDisplayName } from '../../utils/pubkey'; +import { Button } from '../ui/button'; +import { Input } from '../ui/input'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '../ui/dialog'; +import { toast } from '../ui/sonner'; +import type { Contact } from '../../types'; + +const CONTACT_TYPE_LABELS: Record = { + 0: 'Unknown', + 1: 'Client', + 2: 'Repeater', + 3: 'Room', + 4: 'Sensor', +}; + +function formatDate(ts: number): string { + return new Date(ts * 1000).toLocaleDateString([], { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); +} + +function formatDateISO(ts: number): string { + return new Date(ts * 1000).toISOString().slice(0, 10); +} + +function datetimeToUnix(datetimeStr: string): number { + const d = new Date(datetimeStr); + return Math.floor(d.getTime() / 1000); +} + +interface BulkDeleteContactsModalProps { + open: boolean; + onClose: () => void; + contacts: Contact[]; + onDeleted: () => void; +} + +export function BulkDeleteContactsModal({ + open, + onClose, + contacts, + onDeleted, +}: BulkDeleteContactsModalProps) { + const [step, setStep] = useState<'select' | 'confirm'>('select'); + const [selectedKeys, setSelectedKeys] = useState>(new Set()); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const [typeFilter, setTypeFilter] = useState('all'); + const [deleting, setDeleting] = useState(false); + const lastClickedKeyRef = useRef(null); + + const resetAndClose = useCallback(() => { + setStep('select'); + setSelectedKeys(new Set()); + setStartDate(''); + setEndDate(''); + setTypeFilter('all'); + lastClickedKeyRef.current = null; + onClose(); + }, [onClose]); + + const filteredContacts = useMemo(() => { + let list = [...contacts].sort((a, b) => (b.first_seen ?? 0) - (a.first_seen ?? 0)); + if (typeFilter !== 'all') { + list = list.filter((c) => c.type === typeFilter); + } + if (startDate) { + const start = datetimeToUnix(startDate); + list = list.filter((c) => (c.first_seen ?? 0) >= start); + } + if (endDate) { + const end = datetimeToUnix(endDate); + list = list.filter((c) => (c.first_seen ?? 0) <= end); + } + return list; + }, [contacts, typeFilter, startDate, endDate]); + + const handleToggle = (key: string, shiftKey: boolean) => { + if (shiftKey && lastClickedKeyRef.current && lastClickedKeyRef.current !== key) { + const keys = filteredContacts.map((c) => c.public_key); + const lastIdx = keys.indexOf(lastClickedKeyRef.current); + const curIdx = keys.indexOf(key); + if (lastIdx >= 0 && curIdx >= 0) { + const from = Math.min(lastIdx, curIdx); + const to = Math.max(lastIdx, curIdx); + const rangeKeys = keys.slice(from, to + 1); + setSelectedKeys((prev) => { + const next = new Set(prev); + for (const k of rangeKeys) next.add(k); + return next; + }); + lastClickedKeyRef.current = key; + return; + } + } + setSelectedKeys((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + lastClickedKeyRef.current = key; + }; + + const handleSelectAll = () => { + setSelectedKeys(new Set(filteredContacts.map((c) => c.public_key))); + }; + + const handleSelectNone = () => { + setSelectedKeys(new Set()); + }; + + const selectedContacts = useMemo( + () => contacts.filter((c) => selectedKeys.has(c.public_key)), + [contacts, selectedKeys] + ); + + const contactCount = selectedContacts.filter((c) => c.type !== 2).length; + const repeaterCount = selectedContacts.filter((c) => c.type === 2).length; + + const firstSeenDates = selectedContacts.map((c) => c.first_seen ?? 0).filter((t) => t > 0); + const minDate = + firstSeenDates.length > 0 ? formatDateISO(Math.min(...firstSeenDates)) : 'unknown'; + const maxDate = + firstSeenDates.length > 0 ? formatDateISO(Math.max(...firstSeenDates)) : 'unknown'; + + const handleDelete = async () => { + setDeleting(true); + try { + const result = await api.bulkDeleteContacts([...selectedKeys]); + toast.success(`Deleted ${result.deleted} contact${result.deleted === 1 ? '' : 's'}`); + onDeleted(); + resetAndClose(); + } catch (err) { + console.error('Bulk delete failed:', err); + toast.error('Bulk delete failed', { + description: err instanceof Error ? err.message : undefined, + }); + } finally { + setDeleting(false); + } + }; + + return ( + !isOpen && resetAndClose()}> + + + + {step === 'select' ? 'Bulk Delete Contacts' : 'Confirm Deletion'} + + + {step === 'select' + ? 'Select contacts to delete. Message history will be preserved.' + : 'Review the contacts that will be permanently deleted.'} + + + + {step === 'select' && ( + <> +
+
+ + +
+
+ + setStartDate(e.target.value)} + className="w-48 h-8 text-sm" + /> +
+
+ + setEndDate(e.target.value)} + className="w-48 h-8 text-sm" + /> +
+
+ + +
+
+ +
+ {filteredContacts.length} contact{filteredContacts.length === 1 ? '' : 's'} shown + {(startDate || endDate) && ' (filtered)'} + {' · '} + {selectedKeys.size} selected +
+ +
+ {filteredContacts.length === 0 ? ( +
+ No contacts match the selected date range. +
+ ) : ( + + + + + + + + + + + {filteredContacts.map((c) => ( + handleToggle(c.public_key, e.shiftKey)} + > + + + + + + + ))} + +
+ NameTypeKeyCreated
+ + handleToggle( + c.public_key, + e.nativeEvent instanceof MouseEvent && e.nativeEvent.shiftKey + ) + } + onClick={(e) => e.stopPropagation()} + className="rounded border-input" + /> + + {getContactDisplayName(c.name, c.public_key, c.last_advert)} + + {CONTACT_TYPE_LABELS[c.type] ?? 'Unknown'} + + {c.public_key.slice(0, 12)} + + {c.first_seen ? formatDate(c.first_seen) : '—'} +
+ )} +
+ +
+ + +
+ + )} + + {step === 'confirm' && ( + <> +
+ + + + + + + + + + + {selectedContacts.map((c) => ( + + + + + + + ))} + +
NameTypeKeyCreated
+ {getContactDisplayName(c.name, c.public_key, c.last_advert)} + + {CONTACT_TYPE_LABELS[c.type] ?? 'Unknown'} + + {c.public_key.slice(0, 12)} + + {c.first_seen ? formatDate(c.first_seen) : '—'} +
+
+ +
+ + +
+ + )} +
+
+ ); +} diff --git a/frontend/src/components/settings/SettingsDatabaseSection.tsx b/frontend/src/components/settings/SettingsDatabaseSection.tsx index 56e7df0..d4a7ace 100644 --- a/frontend/src/components/settings/SettingsDatabaseSection.tsx +++ b/frontend/src/components/settings/SettingsDatabaseSection.tsx @@ -6,7 +6,8 @@ import { Separator } from '../ui/separator'; import { toast } from '../ui/sonner'; import { api } from '../../api'; import { formatTime } from '../../utils/messageParser'; -import type { AppSettings, AppSettingsUpdate, HealthStatus } from '../../types'; +import { BulkDeleteContactsModal } from './BulkDeleteContactsModal'; +import type { AppSettings, AppSettingsUpdate, Contact, HealthStatus } from '../../types'; export function SettingsDatabaseSection({ appSettings, @@ -17,6 +18,7 @@ export function SettingsDatabaseSection({ blockedNames = [], onToggleBlockedKey, onToggleBlockedName, + contacts = [], className, }: { appSettings: AppSettings; @@ -27,6 +29,7 @@ export function SettingsDatabaseSection({ blockedNames?: string[]; onToggleBlockedKey?: (key: string) => void; onToggleBlockedName?: (name: string) => void; + contacts?: Contact[]; className?: string; }) { const [retentionDays, setRetentionDays] = useState('14'); @@ -34,6 +37,7 @@ export function SettingsDatabaseSection({ const [purgingDecryptedRaw, setPurgingDecryptedRaw] = useState(false); const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false); const [discoveryBlockedTypes, setDiscoveryBlockedTypes] = useState([]); + const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -280,6 +284,25 @@ export function SettingsDatabaseSection({ +
+ +

+ Remove multiple contacts or repeaters at once. Useful for cleaning up spam or unwanted + nodes. Message history will be preserved. +

+ + setBulkDeleteOpen(false)} + contacts={contacts} + onDeleted={() => {}} + /> +
+ + +