mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-06 16:53:38 +02:00
Add contact blocking
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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">✘</span>
|
||||
<span>Unblock this name</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted-foreground text-lg">✘</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">✘</span>
|
||||
<span>Unblock this key</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted-foreground text-lg">✘</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">✘</span>
|
||||
<span>Unblock name “{contact.name}”</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted-foreground text-lg">✘</span>
|
||||
<span>Block name “{contact.name}”</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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,6 +68,8 @@ const baseSettings: AppSettings = {
|
||||
community_mqtt_broker_port: 443,
|
||||
community_mqtt_email: '',
|
||||
flood_scope: '',
|
||||
blocked_keys: [],
|
||||
blocked_names: [],
|
||||
};
|
||||
|
||||
function renderModal(overrides?: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user