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
+55
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
+12
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', {
+99 -6
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">
+5 -15
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>
)}
+12
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}
/>
)}
@@ -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}
+53
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,
};
}
+2
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?: {
+4
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 {