From d5fe9c677f5e4826abd288e006e3e4bb5e460724 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Wed, 4 Mar 2026 18:54:21 -0800 Subject: [PATCH] Add contact blocking --- app/migrations.py | 38 ++++ app/models.py | 8 + app/repository/messages.py | 41 +++++ app/repository/settings.py | 52 +++++- app/routers/messages.py | 12 +- app/routers/settings.py | 36 ++++ frontend/src/App.tsx | 55 ++++++ frontend/src/api.ts | 12 ++ frontend/src/components/ContactInfoPane.tsx | 105 ++++++++++- frontend/src/components/MessageList.tsx | 20 +-- frontend/src/components/SettingsModal.tsx | 12 ++ .../settings/SettingsDatabaseSection.tsx | 69 +++++++ frontend/src/hooks/useAppSettings.ts | 53 ++++++ frontend/src/test/settingsModal.test.tsx | 2 + frontend/src/types.ts | 4 + tests/test_block_lists.py | 170 ++++++++++++++++++ tests/test_migrations.py | 50 +++--- tests/test_repository.py | 2 + 18 files changed, 693 insertions(+), 48 deletions(-) create mode 100644 tests/test_block_lists.py diff --git a/app/migrations.py b/app/migrations.py index 5166b9f..2546092 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -275,6 +275,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int: await set_version(conn, 34) applied += 1 + # Migration 35: Add blocked_keys and blocked_names columns to app_settings + if version < 35: + logger.info("Applying migration 35: add blocked_keys and blocked_names to app_settings") + await _migrate_035_add_block_lists(conn) + await set_version(conn, 35) + applied += 1 + if applied > 0: logger.info( "Applied %d migration(s), schema now at version %d", applied, await get_version(conn) @@ -1976,3 +1983,34 @@ async def _migrate_034_add_flood_scope(conn: aiosqlite.Connection) -> None: logger.debug("app_settings table not ready, skipping flood_scope migration") else: raise + + +async def _migrate_035_add_block_lists(conn: aiosqlite.Connection) -> None: + """Add blocked_keys and blocked_names columns to app_settings. + + These store JSON arrays of blocked public keys and display names. + Blocking hides messages from the UI but does not affect MQTT or bots. + """ + try: + await conn.execute("ALTER TABLE app_settings ADD COLUMN blocked_keys TEXT DEFAULT '[]'") + except Exception as e: + error_msg = str(e).lower() + if "duplicate column" in error_msg: + logger.debug("blocked_keys column already exists, skipping") + elif "no such table" in error_msg: + logger.debug("app_settings table not ready, skipping blocked_keys migration") + else: + raise + + try: + await conn.execute("ALTER TABLE app_settings ADD COLUMN blocked_names TEXT DEFAULT '[]'") + except Exception as e: + error_msg = str(e).lower() + if "duplicate column" in error_msg: + logger.debug("blocked_names column already exists, skipping") + elif "no such table" in error_msg: + logger.debug("app_settings table not ready, skipping blocked_names migration") + else: + raise + + await conn.commit() diff --git a/app/models.py b/app/models.py index af4da03..b365909 100644 --- a/app/models.py +++ b/app/models.py @@ -522,6 +522,14 @@ class AppSettings(BaseModel): default="", description="Outbound flood scope / region name (empty = disabled, no tagging)", ) + blocked_keys: list[str] = Field( + default_factory=list, + description="Public keys whose messages are hidden from the UI", + ) + blocked_names: list[str] = Field( + default_factory=list, + description="Display names whose messages are hidden from the UI", + ) class BusyChannel(BaseModel): diff --git a/app/repository/messages.py b/app/repository/messages.py index cdba75a..28a7003 100644 --- a/app/repository/messages.py +++ b/app/repository/messages.py @@ -170,10 +170,31 @@ class MessageRepository: after: int | None = None, after_id: int | None = None, q: str | None = None, + blocked_keys: list[str] | None = None, + blocked_names: list[str] | None = None, ) -> list[Message]: query = "SELECT * FROM messages WHERE 1=1" params: list[Any] = [] + if blocked_keys: + placeholders = ",".join("?" for _ in blocked_keys) + query += ( + f" AND NOT (outgoing=0 AND (" + f"(type='PRIV' AND LOWER(conversation_key) IN ({placeholders}))" + f" OR (type='CHAN' AND sender_key IS NOT NULL AND LOWER(sender_key) IN ({placeholders}))" + f"))" + ) + params.extend(blocked_keys) + params.extend(blocked_keys) + + if blocked_names: + placeholders = ",".join("?" for _ in blocked_names) + query += ( + f" AND NOT (outgoing=0 AND sender_name IS NOT NULL" + f" AND sender_name IN ({placeholders}))" + ) + params.extend(blocked_names) + if msg_type: query += " AND type = ?" params.append(msg_type) @@ -214,6 +235,8 @@ class MessageRepository: msg_type: str | None = None, conversation_key: str | None = None, context_size: int = 100, + blocked_keys: list[str] | None = None, + blocked_names: list[str] | None = None, ) -> tuple[list[Message], bool, bool]: """Get messages around a target message. @@ -231,6 +254,24 @@ class MessageRepository: where_parts.append(clause.removeprefix("AND ")) base_params.append(norm_key) + if blocked_keys: + placeholders = ",".join("?" for _ in blocked_keys) + where_parts.append( + f"NOT (outgoing=0 AND (" + f"(type='PRIV' AND LOWER(conversation_key) IN ({placeholders}))" + f" OR (type='CHAN' AND sender_key IS NOT NULL AND LOWER(sender_key) IN ({placeholders}))" + f"))" + ) + base_params.extend(blocked_keys) + base_params.extend(blocked_keys) + + if blocked_names: + placeholders = ",".join("?" for _ in blocked_names) + where_parts.append( + f"NOT (outgoing=0 AND sender_name IS NOT NULL AND sender_name IN ({placeholders}))" + ) + base_params.extend(blocked_names) + where_sql = " AND ".join(["1=1", *where_parts]) # 1. Get the target message (must satisfy filters if provided) diff --git a/app/repository/settings.py b/app/repository/settings.py index 599cbad..2914647 100644 --- a/app/repository/settings.py +++ b/app/repository/settings.py @@ -32,7 +32,8 @@ class AppSettingsRepository: mqtt_publish_messages, mqtt_publish_raw_packets, community_mqtt_enabled, community_mqtt_iata, community_mqtt_broker_host, community_mqtt_broker_port, - community_mqtt_email, flood_scope + community_mqtt_email, flood_scope, + blocked_keys, blocked_names FROM app_settings WHERE id = 1 """ ) @@ -82,6 +83,22 @@ class AppSettingsRepository: ) bots = [] + # Parse blocked_keys JSON + blocked_keys: list[str] = [] + if row["blocked_keys"]: + try: + blocked_keys = json.loads(row["blocked_keys"]) + except (json.JSONDecodeError, TypeError): + blocked_keys = [] + + # Parse blocked_names JSON + blocked_names: list[str] = [] + if row["blocked_names"]: + try: + blocked_names = json.loads(row["blocked_names"]) + except (json.JSONDecodeError, TypeError): + blocked_names = [] + # Validate sidebar_sort_order (fallback to "recent" if invalid) sort_order = row["sidebar_sort_order"] if sort_order not in ("recent", "alpha"): @@ -113,6 +130,8 @@ class AppSettingsRepository: community_mqtt_broker_port=row["community_mqtt_broker_port"] or 443, community_mqtt_email=row["community_mqtt_email"] or "", flood_scope=row["flood_scope"] or "", + blocked_keys=blocked_keys, + blocked_names=blocked_names, ) @staticmethod @@ -141,6 +160,8 @@ class AppSettingsRepository: community_mqtt_broker_port: int | None = None, community_mqtt_email: str | None = None, flood_scope: str | None = None, + blocked_keys: list[str] | None = None, + blocked_names: list[str] | None = None, ) -> AppSettings: """Update app settings. Only provided fields are updated.""" updates = [] @@ -244,6 +265,14 @@ class AppSettingsRepository: updates.append("flood_scope = ?") params.append(flood_scope) + if blocked_keys is not None: + updates.append("blocked_keys = ?") + params.append(json.dumps(blocked_keys)) + + if blocked_names is not None: + updates.append("blocked_names = ?") + params.append(json.dumps(blocked_names)) + if updates: query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1" await db.conn.execute(query, params) @@ -272,6 +301,27 @@ class AppSettingsRepository: ] return await AppSettingsRepository.update(favorites=new_favorites) + @staticmethod + async def toggle_blocked_key(key: str) -> AppSettings: + """Toggle a public key in the blocked list. Keys are normalized to lowercase.""" + normalized = key.lower() + settings = await AppSettingsRepository.get() + if normalized in settings.blocked_keys: + new_keys = [k for k in settings.blocked_keys if k != normalized] + else: + new_keys = settings.blocked_keys + [normalized] + return await AppSettingsRepository.update(blocked_keys=new_keys) + + @staticmethod + async def toggle_blocked_name(name: str) -> AppSettings: + """Toggle a display name in the blocked list.""" + settings = await AppSettingsRepository.get() + if name in settings.blocked_names: + new_names = [n for n in settings.blocked_names if n != name] + else: + new_names = settings.blocked_names + [name] + return await AppSettingsRepository.update(blocked_names=new_names) + @staticmethod async def migrate_preferences_from_frontend( favorites: list[dict], diff --git a/app/routers/messages.py b/app/routers/messages.py index 5c77c8e..776ad1c 100644 --- a/app/routers/messages.py +++ b/app/routers/messages.py @@ -14,7 +14,7 @@ from app.models import ( SendDirectMessageRequest, ) from app.radio import radio_manager -from app.repository import AmbiguousPublicKeyPrefixError, MessageRepository +from app.repository import AmbiguousPublicKeyPrefixError, AppSettingsRepository, MessageRepository from app.websocket import broadcast_event logger = logging.getLogger(__name__) @@ -29,11 +29,16 @@ async def get_messages_around( context: int = Query(default=100, ge=1, le=500, description="Number of messages before/after"), ) -> MessagesAroundResponse: """Get messages around a specific message for jump-to-message navigation.""" + settings = await AppSettingsRepository.get() + blocked_keys = settings.blocked_keys or None + blocked_names = settings.blocked_names or None messages, has_older, has_newer = await MessageRepository.get_around( message_id=message_id, msg_type=type, conversation_key=conversation_key, context_size=context, + blocked_keys=blocked_keys, + blocked_names=blocked_names, ) return MessagesAroundResponse(messages=messages, has_older=has_older, has_newer=has_newer) @@ -59,6 +64,9 @@ async def list_messages( q: str | None = Query(default=None, description="Full-text search query"), ) -> list[Message]: """List messages from the database.""" + settings = await AppSettingsRepository.get() + blocked_keys = settings.blocked_keys or None + blocked_names = settings.blocked_names or None return await MessageRepository.get_all( limit=limit, offset=offset, @@ -69,6 +77,8 @@ async def list_messages( after=after, after_id=after_id, q=q, + blocked_keys=blocked_keys, + blocked_names=blocked_names, ) diff --git a/app/routers/settings.py b/app/routers/settings.py index 08c7fb7..55e20b3 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -125,6 +125,22 @@ class AppSettingsUpdate(BaseModel): default=None, description="Outbound flood scope / region name (empty = disabled)", ) + blocked_keys: list[str] | None = Field( + default=None, + description="Public keys whose messages are hidden from the UI", + ) + blocked_names: list[str] | None = Field( + default=None, + description="Display names whose messages are hidden from the UI", + ) + + +class BlockKeyRequest(BaseModel): + key: str = Field(description="Public key to toggle block status") + + +class BlockNameRequest(BaseModel): + name: str = Field(description="Display name to toggle block status") class FavoriteRequest(BaseModel): @@ -241,6 +257,12 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings: kwargs["community_mqtt_email"] = update.community_mqtt_email community_mqtt_changed = True + # Block lists + if update.blocked_keys is not None: + kwargs["blocked_keys"] = [k.lower() for k in update.blocked_keys] + if update.blocked_names is not None: + kwargs["blocked_names"] = update.blocked_names + # Flood scope flood_scope_changed = False if update.flood_scope is not None: @@ -317,6 +339,20 @@ async def toggle_favorite(request: FavoriteRequest) -> AppSettings: return result +@router.post("/blocked-keys/toggle", response_model=AppSettings) +async def toggle_blocked_key(request: BlockKeyRequest) -> AppSettings: + """Toggle a public key's blocked status.""" + logger.info("Toggling blocked key: %s", request.key[:12]) + return await AppSettingsRepository.toggle_blocked_key(request.key) + + +@router.post("/blocked-names/toggle", response_model=AppSettings) +async def toggle_blocked_name(request: BlockNameRequest) -> AppSettings: + """Toggle a display name's blocked status.""" + logger.info("Toggling blocked name: %s", request.name) + return await AppSettingsRepository.toggle_blocked_name(request.name) + + @router.post("/migrate", response_model=MigratePreferencesResponse) async def migrate_preferences(request: MigratePreferencesRequest) -> MigratePreferencesResponse: """Migrate all preferences from frontend localStorage to database. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5f34150..d514084 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -117,6 +117,8 @@ export function App() { handleSaveAppSettings, handleSortOrderChange, handleToggleFavorite, + handleToggleBlockedKey, + handleToggleBlockedName, } = useAppSettings(); // Keep user's name in ref for mention detection in WebSocket callback @@ -125,6 +127,14 @@ export function App() { myNameRef.current = config?.name ?? null; }, [config?.name]); + // Keep block lists in refs for WS callback filtering + const blockedKeysRef = useRef([]); + const blockedNamesRef = useRef([]); + useEffect(() => { + blockedKeysRef.current = appSettings?.blocked_keys ?? []; + blockedNamesRef.current = appSettings?.blocked_names ?? []; + }, [appSettings?.blocked_keys, appSettings?.blocked_names]); + // Check if a message mentions the user const checkMention = useCallback( (text: string): boolean => messageContainsMention(text, myNameRef.current), @@ -256,6 +266,21 @@ export function App() { .catch(console.error); }, onMessage: (msg: Message) => { + // Filter blocked contacts on incoming (non-outgoing) messages + if (!msg.outgoing) { + const bKeys = blockedKeysRef.current; + const bNames = blockedNamesRef.current; + // Block DMs by key + if ( + bKeys.length > 0 && + msg.type === 'PRIV' && + bKeys.includes(msg.conversation_key.toLowerCase()) + ) + return; + // Block by sender name (works for channel messages) + if (bNames.length > 0 && msg.sender_name && bNames.includes(msg.sender_name)) return; + } + const activeConv = activeConversationRef.current; // Check if message belongs to the active conversation @@ -441,6 +466,28 @@ export function App() { } }, [activeConversation]); + // Wrappers that clear cache and hard-refetch messages after block changes. + // jumpToBottom does cache.remove + fetchMessages(true) which fully replaces + // the message state; triggerReconcile only merges diffs and would keep + // blocked messages already in state. + const handleBlockKey = useCallback( + async (key: string) => { + await handleToggleBlockedKey(key); + messageCache.clear(); + jumpToBottom(); + }, + [handleToggleBlockedKey, jumpToBottom] + ); + + const handleBlockName = useCallback( + async (name: string) => { + await handleToggleBlockedName(name); + messageCache.clear(); + jumpToBottom(); + }, + [handleToggleBlockedName, jumpToBottom] + ); + const handleCloseSettingsView = useCallback(() => { startTransition(() => setShowSettings(false)); setSidebarOpen(false); @@ -796,6 +843,10 @@ export function App() { onHealthRefresh={handleHealthRefresh} onRefreshAppSettings={fetchAppSettings} onLocalLabelChange={setLocalLabel} + blockedKeys={appSettings?.blocked_keys} + blockedNames={appSettings?.blocked_names} + onToggleBlockedKey={handleBlockKey} + onToggleBlockedName={handleBlockName} /> @@ -861,6 +912,10 @@ export function App() { favorites={favorites} onToggleFavorite={handleToggleFavorite} onNavigateToChannel={handleNavigateToChannel} + blockedKeys={appSettings?.blocked_keys} + blockedNames={appSettings?.blocked_names} + onToggleBlockedKey={handleBlockKey} + onToggleBlockedName={handleBlockName} /> + fetchJson('/settings/blocked-keys/toggle', { + method: 'POST', + body: JSON.stringify({ key }), + }), + toggleBlockedName: (name: string) => + fetchJson('/settings/blocked-names/toggle', { + method: 'POST', + body: JSON.stringify({ name }), + }), + // Favorites toggleFavorite: (type: Favorite['type'], id: string) => fetchJson('/settings/favorites/toggle', { diff --git a/frontend/src/components/ContactInfoPane.tsx b/frontend/src/components/ContactInfoPane.tsx index df0e242..c071b78 100644 --- a/frontend/src/components/ContactInfoPane.tsx +++ b/frontend/src/components/ContactInfoPane.tsx @@ -26,6 +26,10 @@ interface ContactInfoPaneProps { favorites: Favorite[]; onToggleFavorite: (type: 'channel' | 'contact', id: string) => void; onNavigateToChannel?: (channelKey: string) => void; + blockedKeys?: string[]; + blockedNames?: string[]; + onToggleBlockedKey?: (key: string) => void; + onToggleBlockedName?: (name: string) => void; } export function ContactInfoPane({ @@ -36,17 +40,23 @@ export function ContactInfoPane({ favorites, onToggleFavorite, onNavigateToChannel, + blockedKeys = [], + blockedNames = [], + onToggleBlockedKey, + onToggleBlockedName, }: ContactInfoPaneProps) { + const isNameOnly = contactKey?.startsWith('name:') ?? false; + const nameOnlyValue = isNameOnly && contactKey ? contactKey.slice(5) : null; + const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(false); // Get live contact data from contacts array (real-time via WS) - const liveContact = contactKey - ? (contacts.find((c) => c.public_key === contactKey) ?? null) - : null; + const liveContact = + contactKey && !isNameOnly ? (contacts.find((c) => c.public_key === contactKey) ?? null) : null; useEffect(() => { - if (!contactKey) { + if (!contactKey || isNameOnly) { setDetail(null); return; } @@ -70,7 +80,7 @@ export function ContactInfoPane({ return () => { cancelled = true; }; - }, [contactKey]); + }, [contactKey, isNameOnly]); // Use live contact data where available, fall back to detail snapshot const contact = liveContact ?? detail?.contact ?? null; @@ -90,7 +100,46 @@ export function ContactInfoPane({ Contact Info - {loading && !detail ? ( + {isNameOnly && nameOnlyValue ? ( +
+ {/* Name-only header */} +
+
+ +
+

{nameOnlyValue}

+

+ We have not heard an advertisement associated with this name, so we cannot + identify their key. +

+
+
+
+ + {/* Block by name toggle */} + {onToggleBlockedName && ( +
+ +
+ )} +
+ ) : loading && !detail ? (
Loading...
@@ -209,6 +258,50 @@ export function ContactInfoPane({ + {/* Block toggles */} + {(onToggleBlockedKey || onToggleBlockedName) && ( +
+ {onToggleBlockedKey && ( + + )} + {onToggleBlockedName && contact.name && ( + + )} +
+ )} + {/* AKA (Name History) - only show if more than one name */} {detail && detail.name_history.length > 1 && (
diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index 50f0eee..518805f 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -530,26 +530,16 @@ export function MessageList({
{showAvatar && avatarKey && ( onOpenContactInfo(avatarKey) - : undefined - } + role={onOpenContactInfo ? 'button' : undefined} + tabIndex={onOpenContactInfo ? 0 : undefined} + onKeyDown={onOpenContactInfo ? handleKeyboardActivate : undefined} + onClick={onOpenContactInfo ? () => onOpenContactInfo(avatarKey) : undefined} > )} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 16d4b38..928a16d 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -33,6 +33,10 @@ interface SettingsModalBaseProps { onHealthRefresh: () => Promise; onRefreshAppSettings: () => Promise; onLocalLabelChange?: (label: LocalLabel) => void; + blockedKeys?: string[]; + blockedNames?: string[]; + onToggleBlockedKey?: (key: string) => void; + onToggleBlockedName?: (name: string) => void; } type SettingsModalProps = SettingsModalBaseProps & @@ -57,6 +61,10 @@ export function SettingsModal(props: SettingsModalProps) { onHealthRefresh, onRefreshAppSettings, onLocalLabelChange, + blockedKeys, + blockedNames, + onToggleBlockedKey, + onToggleBlockedName, } = props; const externalSidebarNav = props.externalSidebarNav === true; const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined; @@ -234,6 +242,10 @@ export function SettingsModal(props: SettingsModalProps) { onSaveAppSettings={onSaveAppSettings} onHealthRefresh={onHealthRefresh} onLocalLabelChange={onLocalLabelChange} + blockedKeys={blockedKeys} + blockedNames={blockedNames} + onToggleBlockedKey={onToggleBlockedKey} + onToggleBlockedName={onToggleBlockedName} className={sectionContentClass} /> )} diff --git a/frontend/src/components/settings/SettingsDatabaseSection.tsx b/frontend/src/components/settings/SettingsDatabaseSection.tsx index ec58d85..b7b6d6e 100644 --- a/frontend/src/components/settings/SettingsDatabaseSection.tsx +++ b/frontend/src/components/settings/SettingsDatabaseSection.tsx @@ -21,6 +21,10 @@ export function SettingsDatabaseSection({ onSaveAppSettings, onHealthRefresh, onLocalLabelChange, + blockedKeys = [], + blockedNames = [], + onToggleBlockedKey, + onToggleBlockedName, className, }: { appSettings: AppSettings; @@ -28,6 +32,10 @@ export function SettingsDatabaseSection({ onSaveAppSettings: (update: AppSettingsUpdate) => Promise; onHealthRefresh: () => Promise; onLocalLabelChange?: (label: LocalLabel) => void; + blockedKeys?: string[]; + blockedNames?: string[]; + onToggleBlockedKey?: (key: string) => void; + onToggleBlockedName?: (name: string) => void; className?: string; }) { const [retentionDays, setRetentionDays] = useState('14'); @@ -280,6 +288,67 @@ export function SettingsDatabaseSection({

+ + +
+ +

+ Blocking only hides messages from the UI. MQTT forwarding and bot responses are not + affected. Messages are still stored and will reappear if unblocked. +

+ + {blockedKeys.length === 0 && blockedNames.length === 0 ? ( +

No blocked contacts

+ ) : ( +
+ {blockedKeys.length > 0 && ( +
+ Blocked Keys +
+ {blockedKeys.map((key) => ( +
+ {key} + {onToggleBlockedKey && ( + + )} +
+ ))} +
+
+ )} + {blockedNames.length > 0 && ( +
+ Blocked Names +
+ {blockedNames.map((name) => ( +
+ {name} + {onToggleBlockedName && ( + + )} +
+ ))} +
+
+ )} +
+ )} +
+ {error && (
{error} diff --git a/frontend/src/hooks/useAppSettings.ts b/frontend/src/hooks/useAppSettings.ts index 7969934..a9118be 100644 --- a/frontend/src/hooks/useAppSettings.ts +++ b/frontend/src/hooks/useAppSettings.ts @@ -62,6 +62,57 @@ export function useAppSettings() { [appSettings?.sidebar_sort_order] ); + const handleToggleBlockedKey = useCallback(async (key: string) => { + const normalizedKey = key.toLowerCase(); + setAppSettings((prev) => { + if (!prev) return prev; + const current = prev.blocked_keys ?? []; + const wasBlocked = current.includes(normalizedKey); + const optimistic = wasBlocked + ? current.filter((k) => k !== normalizedKey) + : [...current, normalizedKey]; + return { ...prev, blocked_keys: optimistic }; + }); + + try { + const updatedSettings = await api.toggleBlockedKey(key); + setAppSettings(updatedSettings); + } catch (err) { + console.error('Failed to toggle blocked key:', err); + try { + const settings = await api.getSettings(); + setAppSettings(settings); + } catch { + // If refetch also fails, leave optimistic state + } + toast.error('Failed to update blocked key'); + } + }, []); + + const handleToggleBlockedName = useCallback(async (name: string) => { + setAppSettings((prev) => { + if (!prev) return prev; + const current = prev.blocked_names ?? []; + const wasBlocked = current.includes(name); + const optimistic = wasBlocked ? current.filter((n) => n !== name) : [...current, name]; + return { ...prev, blocked_names: optimistic }; + }); + + try { + const updatedSettings = await api.toggleBlockedName(name); + setAppSettings(updatedSettings); + } catch (err) { + console.error('Failed to toggle blocked name:', err); + try { + const settings = await api.getSettings(); + setAppSettings(settings); + } catch { + // If refetch also fails, leave optimistic state + } + toast.error('Failed to update blocked name'); + } + }, []); + const handleToggleFavorite = useCallback(async (type: 'channel' | 'contact', id: string) => { setAppSettings((prev) => { if (!prev) return prev; @@ -149,5 +200,7 @@ export function useAppSettings() { handleSaveAppSettings, handleSortOrderChange, handleToggleFavorite, + handleToggleBlockedKey, + handleToggleBlockedName, }; } diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index 66b69a6..4efac0d 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -68,6 +68,8 @@ const baseSettings: AppSettings = { community_mqtt_broker_port: 443, community_mqtt_email: '', flood_scope: '', + blocked_keys: [], + blocked_names: [], }; function renderModal(overrides?: { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 2c79de0..6260250 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -229,6 +229,8 @@ export interface AppSettings { community_mqtt_broker_port: number; community_mqtt_email: string; flood_scope: string; + blocked_keys: string[]; + blocked_names: string[]; } export interface AppSettingsUpdate { @@ -252,6 +254,8 @@ export interface AppSettingsUpdate { community_mqtt_broker_port?: number; community_mqtt_email?: string; flood_scope?: string; + blocked_keys?: string[]; + blocked_names?: string[]; } export interface MigratePreferencesRequest { diff --git a/tests/test_block_lists.py b/tests/test_block_lists.py new file mode 100644 index 0000000..3931711 --- /dev/null +++ b/tests/test_block_lists.py @@ -0,0 +1,170 @@ +"""Tests for blocked keys and blocked names feature.""" + +import time + +import pytest + +from app.repository import AppSettingsRepository, MessageRepository +from app.routers.settings import ( + BlockKeyRequest, + BlockNameRequest, + toggle_blocked_key, + toggle_blocked_name, +) + + +class TestBlockListRepository: + @pytest.mark.asyncio + async def test_toggle_blocked_key_adds_and_removes(self, test_db): + result = await AppSettingsRepository.toggle_blocked_key("AABB" * 16) + assert ("aabb" * 16) in result.blocked_keys + + result = await AppSettingsRepository.toggle_blocked_key("AABB" * 16) + assert ("aabb" * 16) not in result.blocked_keys + + @pytest.mark.asyncio + async def test_blocked_key_normalization(self, test_db): + result = await AppSettingsRepository.toggle_blocked_key("AABBccDD" * 8) + assert ("aabbccdd" * 8) in result.blocked_keys + + @pytest.mark.asyncio + async def test_toggle_blocked_name_adds_and_removes(self, test_db): + result = await AppSettingsRepository.toggle_blocked_name("BadUser") + assert "BadUser" in result.blocked_names + + result = await AppSettingsRepository.toggle_blocked_name("BadUser") + assert "BadUser" not in result.blocked_names + + +class TestBlockListRouterEndpoints: + @pytest.mark.asyncio + async def test_toggle_blocked_key_round_trip(self, test_db): + key = "ff" * 32 + result = await toggle_blocked_key(BlockKeyRequest(key=key)) + assert key in result.blocked_keys + + result = await toggle_blocked_key(BlockKeyRequest(key=key)) + assert key not in result.blocked_keys + + @pytest.mark.asyncio + async def test_toggle_blocked_name_round_trip(self, test_db): + result = await toggle_blocked_name(BlockNameRequest(name="Spammer")) + assert "Spammer" in result.blocked_names + + result = await toggle_blocked_name(BlockNameRequest(name="Spammer")) + assert "Spammer" not in result.blocked_names + + +class TestMessageBlockFiltering: + @pytest.fixture(autouse=True) + async def _seed_messages(self, test_db): + """Seed messages for filtering tests.""" + now = int(time.time()) + blocked_key = "aa" * 32 + normal_key = "bb" * 32 + + # Incoming DM from blocked key + await MessageRepository.create( + msg_type="PRIV", + text="blocked dm", + received_at=now, + conversation_key=blocked_key, + sender_timestamp=now, + ) + + # Incoming DM from normal key + await MessageRepository.create( + msg_type="PRIV", + text="normal dm", + received_at=now + 1, + conversation_key=normal_key, + sender_timestamp=now + 1, + ) + + # Outgoing DM to blocked key (should NOT be filtered) + await MessageRepository.create( + msg_type="PRIV", + text="outgoing to blocked", + received_at=now + 2, + conversation_key=blocked_key, + sender_timestamp=now + 2, + outgoing=True, + ) + + # Channel message from blocked name + await MessageRepository.create( + msg_type="CHAN", + text="BlockedName: spam message", + received_at=now + 3, + conversation_key="CC" * 16, + sender_timestamp=now + 3, + sender_name="BlockedName", + sender_key="dd" * 32, + ) + + # Channel message from normal sender + await MessageRepository.create( + msg_type="CHAN", + text="NormalUser: hello", + received_at=now + 4, + conversation_key="CC" * 16, + sender_timestamp=now + 4, + sender_name="NormalUser", + sender_key=normal_key, + ) + + # Channel message from blocked sender key + await MessageRepository.create( + msg_type="CHAN", + text="AnotherName: also blocked", + received_at=now + 5, + conversation_key="CC" * 16, + sender_timestamp=now + 5, + sender_name="AnotherName", + sender_key=blocked_key, + ) + + @pytest.mark.asyncio + async def test_get_all_filters_blocked_key_dms(self, test_db): + blocked_key = "aa" * 32 + msgs = await MessageRepository.get_all(blocked_keys=[blocked_key]) + texts = [m.text for m in msgs] + assert "blocked dm" not in texts + assert "normal dm" in texts + + @pytest.mark.asyncio + async def test_get_all_never_filters_outgoing(self, test_db): + blocked_key = "aa" * 32 + msgs = await MessageRepository.get_all(blocked_keys=[blocked_key]) + texts = [m.text for m in msgs] + assert "outgoing to blocked" in texts + + @pytest.mark.asyncio + async def test_get_all_filters_blocked_name(self, test_db): + msgs = await MessageRepository.get_all(blocked_names=["BlockedName"]) + texts = [m.text for m in msgs] + assert "BlockedName: spam message" not in texts + assert "NormalUser: hello" in texts + + @pytest.mark.asyncio + async def test_get_all_filters_blocked_sender_key_in_channels(self, test_db): + blocked_key = "aa" * 32 + msgs = await MessageRepository.get_all(blocked_keys=[blocked_key]) + texts = [m.text for m in msgs] + assert "AnotherName: also blocked" not in texts + + @pytest.mark.asyncio + async def test_get_around_filters_blocked(self, test_db): + # Get a normal message ID to center around + all_msgs = await MessageRepository.get_all() + normal_msg = next(m for m in all_msgs if m.text == "normal dm") + + blocked_key = "aa" * 32 + msgs, _, _ = await MessageRepository.get_around( + message_id=normal_msg.id, + blocked_keys=[blocked_key], + ) + texts = [m.text for m in msgs] + assert "blocked dm" not in texts + assert "normal dm" in texts + assert "outgoing to blocked" in texts diff --git a/tests/test_migrations.py b/tests/test_migrations.py index b5bba1c..5c31898 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -100,8 +100,8 @@ class TestMigration001: # Run migrations applied = await run_migrations(conn) - assert applied == 34 # All migrations run - assert await get_version(conn) == 34 + assert applied == 35 # All migrations run + assert await get_version(conn) == 35 # Verify columns exist by inserting and selecting await conn.execute( @@ -183,9 +183,9 @@ class TestMigration001: applied1 = await run_migrations(conn) applied2 = await run_migrations(conn) - assert applied1 == 34 # All migrations run + assert applied1 == 35 # All migrations run assert applied2 == 0 # No migrations on second run - assert await get_version(conn) == 34 + assert await get_version(conn) == 35 finally: await conn.close() @@ -246,8 +246,8 @@ class TestMigration001: applied = await run_migrations(conn) # All migrations applied (version incremented) but no error - assert applied == 34 - assert await get_version(conn) == 34 + assert applied == 35 + assert await get_version(conn) == 35 finally: await conn.close() @@ -376,8 +376,8 @@ class TestMigration013: # Run migration 13 (plus 14-34 which also run) applied = await run_migrations(conn) - assert applied == 22 - assert await get_version(conn) == 34 + assert applied == 23 + assert await get_version(conn) == 35 # Verify bots array was created with migrated data cursor = await conn.execute("SELECT bots FROM app_settings WHERE id = 1") @@ -497,7 +497,7 @@ class TestMigration018: assert await cursor.fetchone() is not None await run_migrations(conn) - assert await get_version(conn) == 34 + assert await get_version(conn) == 35 # Verify autoindex is gone cursor = await conn.execute( @@ -575,8 +575,8 @@ class TestMigration018: await conn.commit() applied = await run_migrations(conn) - assert applied == 17 # Migrations 18-34 run (18+19 skip internally) - assert await get_version(conn) == 34 + assert applied == 18 # Migrations 18-35 run (18+19 skip internally) + assert await get_version(conn) == 35 finally: await conn.close() @@ -648,7 +648,7 @@ class TestMigration019: assert await cursor.fetchone() is not None await run_migrations(conn) - assert await get_version(conn) == 34 + assert await get_version(conn) == 35 # Verify autoindex is gone cursor = await conn.execute( @@ -714,8 +714,8 @@ class TestMigration020: assert (await cursor.fetchone())[0] == "delete" applied = await run_migrations(conn) - assert applied == 15 # Migrations 20-34 - assert await get_version(conn) == 34 + assert applied == 16 # Migrations 20-35 + assert await get_version(conn) == 35 # Verify WAL mode cursor = await conn.execute("PRAGMA journal_mode") @@ -745,7 +745,7 @@ class TestMigration020: await set_version(conn, 20) applied = await run_migrations(conn) - assert applied == 14 # Migrations 21-34 still run + assert applied == 15 # Migrations 21-35 still run # Still WAL + INCREMENTAL cursor = await conn.execute("PRAGMA journal_mode") @@ -803,8 +803,8 @@ class TestMigration028: await conn.commit() applied = await run_migrations(conn) - assert applied == 7 - assert await get_version(conn) == 34 + assert applied == 8 + assert await get_version(conn) == 35 # Verify payload_hash column is now BLOB cursor = await conn.execute("PRAGMA table_info(raw_packets)") @@ -873,8 +873,8 @@ class TestMigration028: await conn.commit() applied = await run_migrations(conn) - assert applied == 7 # Version still bumped - assert await get_version(conn) == 34 + assert applied == 8 # Version still bumped + assert await get_version(conn) == 35 # Verify data unchanged cursor = await conn.execute("SELECT payload_hash FROM raw_packets") @@ -923,8 +923,8 @@ class TestMigration032: await conn.commit() applied = await run_migrations(conn) - assert applied == 3 - assert await get_version(conn) == 34 + assert applied == 4 + assert await get_version(conn) == 35 # Verify all columns exist with correct defaults cursor = await conn.execute( @@ -996,8 +996,8 @@ class TestMigration034: await conn.commit() applied = await run_migrations(conn) - assert applied == 1 - assert await get_version(conn) == 34 + assert applied == 2 + assert await get_version(conn) == 35 # Verify column exists with correct default cursor = await conn.execute("SELECT flood_scope FROM app_settings WHERE id = 1") @@ -1039,8 +1039,8 @@ class TestMigration033: await conn.commit() applied = await run_migrations(conn) - assert applied == 2 - assert await get_version(conn) == 34 + assert applied == 3 + assert await get_version(conn) == 35 cursor = await conn.execute( "SELECT key, name, is_hashtag, on_radio FROM channels WHERE key = ?", diff --git a/tests/test_repository.py b/tests/test_repository.py index 5044959..b8ccda7 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -508,6 +508,8 @@ class TestAppSettingsRepository: "community_mqtt_broker_port": 443, "community_mqtt_email": "", "flood_scope": "", + "blocked_keys": "[]", + "blocked_names": "[]", } ) mock_conn.execute = AsyncMock(return_value=mock_cursor)