Add contact blocking

This commit is contained in:
Jack Kingsman
2026-03-04 18:54:21 -08:00
parent 145609faf9
commit d5fe9c677f
18 changed files with 693 additions and 48 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string[]>([]);
const blockedNamesRef = useRef<string[]>([]);
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}
/>
</Suspense>
</div>
@@ -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}
/>
<ChannelInfoPane

View File

@@ -254,6 +254,18 @@ export const api = {
body: JSON.stringify(settings),
}),
// Block lists
toggleBlockedKey: (key: string) =>
fetchJson<AppSettings>('/settings/blocked-keys/toggle', {
method: 'POST',
body: JSON.stringify({ key }),
}),
toggleBlockedName: (name: string) =>
fetchJson<AppSettings>('/settings/blocked-names/toggle', {
method: 'POST',
body: JSON.stringify({ name }),
}),
// Favorites
toggleFavorite: (type: Favorite['type'], id: string) =>
fetchJson<AppSettings>('/settings/favorites/toggle', {

View File

@@ -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<ContactDetail | null>(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({
<SheetTitle>Contact Info</SheetTitle>
</SheetHeader>
{loading && !detail ? (
{isNameOnly && nameOnlyValue ? (
<div className="flex-1 overflow-y-auto">
{/* Name-only header */}
<div className="px-5 pt-5 pb-4 border-b border-border">
<div className="flex items-start gap-4">
<ContactAvatar name={nameOnlyValue} publicKey={`name:${nameOnlyValue}`} size={56} />
<div className="flex-1 min-w-0">
<h2 className="text-lg font-semibold truncate">{nameOnlyValue}</h2>
<p className="text-xs text-muted-foreground mt-1">
We have not heard an advertisement associated with this name, so we cannot
identify their key.
</p>
</div>
</div>
</div>
{/* Block by name toggle */}
{onToggleBlockedName && (
<div className="px-5 py-3 border-b border-border">
<button
type="button"
className="text-sm flex items-center gap-2 hover:text-primary transition-colors"
onClick={() => onToggleBlockedName(nameOnlyValue)}
>
{blockedNames.includes(nameOnlyValue) ? (
<>
<span className="text-destructive text-lg">&#x2718;</span>
<span>Unblock this name</span>
</>
) : (
<>
<span className="text-muted-foreground text-lg">&#x2718;</span>
<span>Block this name</span>
</>
)}
</button>
</div>
)}
</div>
) : loading && !detail ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Loading...
</div>
@@ -209,6 +258,50 @@ export function ContactInfoPane({
</button>
</div>
{/* Block toggles */}
{(onToggleBlockedKey || onToggleBlockedName) && (
<div className="px-5 py-3 border-b border-border space-y-2">
{onToggleBlockedKey && (
<button
type="button"
className="text-sm flex items-center gap-2 hover:text-primary transition-colors"
onClick={() => onToggleBlockedKey(contact.public_key)}
>
{blockedKeys.includes(contact.public_key.toLowerCase()) ? (
<>
<span className="text-destructive text-lg">&#x2718;</span>
<span>Unblock this key</span>
</>
) : (
<>
<span className="text-muted-foreground text-lg">&#x2718;</span>
<span>Block this key</span>
</>
)}
</button>
)}
{onToggleBlockedName && contact.name && (
<button
type="button"
className="text-sm flex items-center gap-2 hover:text-primary transition-colors"
onClick={() => onToggleBlockedName(contact.name!)}
>
{blockedNames.includes(contact.name) ? (
<>
<span className="text-destructive text-lg">&#x2718;</span>
<span>Unblock name &ldquo;{contact.name}&rdquo;</span>
</>
) : (
<>
<span className="text-muted-foreground text-lg">&#x2718;</span>
<span>Block name &ldquo;{contact.name}&rdquo;</span>
</>
)}
</button>
)}
</div>
)}
{/* AKA (Name History) - only show if more than one name */}
{detail && detail.name_history.length > 1 && (
<div className="px-5 py-3 border-b border-border">

View File

@@ -530,26 +530,16 @@ export function MessageList({
<div className="w-10 flex-shrink-0 flex items-start pt-0.5">
{showAvatar && avatarKey && (
<span
role={
onOpenContactInfo && !avatarKey.startsWith('name:') ? 'button' : undefined
}
tabIndex={onOpenContactInfo && !avatarKey.startsWith('name:') ? 0 : undefined}
onKeyDown={
onOpenContactInfo && !avatarKey.startsWith('name:')
? handleKeyboardActivate
: undefined
}
onClick={
onOpenContactInfo && !avatarKey.startsWith('name:')
? () => onOpenContactInfo(avatarKey)
: undefined
}
role={onOpenContactInfo ? 'button' : undefined}
tabIndex={onOpenContactInfo ? 0 : undefined}
onKeyDown={onOpenContactInfo ? handleKeyboardActivate : undefined}
onClick={onOpenContactInfo ? () => onOpenContactInfo(avatarKey) : undefined}
>
<ContactAvatar
name={avatarName}
publicKey={avatarKey}
size={32}
clickable={!!onOpenContactInfo && !avatarKey.startsWith('name:')}
clickable={!!onOpenContactInfo}
/>
</span>
)}

View File

@@ -33,6 +33,10 @@ interface SettingsModalBaseProps {
onHealthRefresh: () => Promise<void>;
onRefreshAppSettings: () => Promise<void>;
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}
/>
)}

View File

@@ -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<void>;
onHealthRefresh: () => Promise<void>;
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({
</p>
</div>
<Separator />
<div className="space-y-3">
<Label>Blocked Contacts</Label>
<p className="text-xs text-muted-foreground">
Blocking only hides messages from the UI. MQTT forwarding and bot responses are not
affected. Messages are still stored and will reappear if unblocked.
</p>
{blockedKeys.length === 0 && blockedNames.length === 0 ? (
<p className="text-sm text-muted-foreground italic">No blocked contacts</p>
) : (
<div className="space-y-2">
{blockedKeys.length > 0 && (
<div>
<span className="text-xs text-muted-foreground font-medium">Blocked Keys</span>
<div className="mt-1 space-y-1">
{blockedKeys.map((key) => (
<div key={key} className="flex items-center justify-between gap-2">
<span className="text-xs font-mono truncate flex-1">{key}</span>
{onToggleBlockedKey && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleBlockedKey(key)}
className="h-7 text-xs flex-shrink-0"
>
Unblock
</Button>
)}
</div>
))}
</div>
</div>
)}
{blockedNames.length > 0 && (
<div>
<span className="text-xs text-muted-foreground font-medium">Blocked Names</span>
<div className="mt-1 space-y-1">
{blockedNames.map((name) => (
<div key={name} className="flex items-center justify-between gap-2">
<span className="text-sm truncate flex-1">{name}</span>
{onToggleBlockedName && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleBlockedName(name)}
className="h-7 text-xs flex-shrink-0"
>
Unblock
</Button>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
{error && (
<div className="text-sm text-destructive" role="alert">
{error}

View File

@@ -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,
};
}

View File

@@ -68,6 +68,8 @@ const baseSettings: AppSettings = {
community_mqtt_broker_port: 443,
community_mqtt_email: '',
flood_scope: '',
blocked_keys: [],
blocked_names: [],
};
function renderModal(overrides?: {

View File

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

170
tests/test_block_lists.py Normal file
View File

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

View File

@@ -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 = ?",

View File

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