From e27beb77369207611b0b22a128827c3072c8bce3 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Wed, 4 Mar 2026 06:21:43 +0800 Subject: [PATCH] Fix toast notifications, require accept_connections for senders - Rewrite NotificationToaster with max-ID watermark for reliable new-notification detection and faster unread count polling (15s) - Block connection search and requests when sender has accept_connections disabled (backend + frontend gate) - Remove duplicate sender_settings fetch in send_connection_request - Show actionable error messages in toast respond failures Co-Authored-By: Claude Opus 4.6 --- backend/app/routers/connections.py | 18 ++++++-- .../connections/ConnectionSearch.tsx | 28 +++++++++++- .../notifications/NotificationToaster.tsx | 43 ++++++++++++------- frontend/src/hooks/useNotifications.ts | 15 +------ 4 files changed, 71 insertions(+), 33 deletions(-) diff --git a/backend/app/routers/connections.py b/backend/app/routers/connections.py index c46a83a..9329acf 100644 --- a/backend/app/routers/connections.py +++ b/backend/app/routers/connections.py @@ -88,6 +88,11 @@ async def search_user( # Always sleep to prevent timing attacks await asyncio.sleep(0.05) + # Sender must have accept_connections enabled to search + sender_settings = await _get_settings_for_user(db, current_user.id) + if not sender_settings or not sender_settings.accept_connections: + return UmbralSearchResponse(found=False) + # Don't find yourself if body.umbral_name == current_user.umbral_name: return UmbralSearchResponse(found=False) @@ -132,7 +137,15 @@ async def send_connection_request( if target.id == current_user.id: raise HTTPException(status_code=400, detail="Cannot send a connection request to yourself") - # Check accept_connections + # Sender must have accept_connections enabled to participate + sender_settings = await _get_settings_for_user(db, current_user.id) + if not sender_settings or not sender_settings.accept_connections: + raise HTTPException( + status_code=403, + detail="You must enable 'Accept Connections' in your settings before sending requests", + ) + + # Check accept_connections on target target_settings = await _get_settings_for_user(db, target.id) if not target_settings or not target_settings.accept_connections: raise HTTPException(status_code=404, detail="User not found") @@ -185,8 +198,7 @@ async def send_connection_request( db.add(conn_request) await db.flush() # populate conn_request.id for source_id - # Create in-app notification for receiver - sender_settings = await _get_settings_for_user(db, current_user.id) + # Create in-app notification for receiver (sender_settings already fetched above) sender_display = (sender_settings.preferred_name if sender_settings else None) or current_user.umbral_name await create_notification( diff --git a/frontend/src/components/connections/ConnectionSearch.tsx b/frontend/src/components/connections/ConnectionSearch.tsx index b60cb2d..e7e2ecb 100644 --- a/frontend/src/components/connections/ConnectionSearch.tsx +++ b/frontend/src/components/connections/ConnectionSearch.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; -import { Search, UserPlus, Loader2, AlertCircle, CheckCircle } from 'lucide-react'; +import { Search, UserPlus, Loader2, AlertCircle, CheckCircle, Settings } from 'lucide-react'; import { toast } from 'sonner'; +import { useNavigate } from 'react-router-dom'; import { Dialog, DialogContent, @@ -12,6 +13,7 @@ import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { useConnections } from '@/hooks/useConnections'; +import { useSettings } from '@/hooks/useSettings'; import { getErrorMessage } from '@/lib/api'; interface ConnectionSearchProps { @@ -21,10 +23,14 @@ interface ConnectionSearchProps { export default function ConnectionSearch({ open, onOpenChange }: ConnectionSearchProps) { const { search, isSearching, sendRequest, isSending } = useConnections(); + const { settings } = useSettings(); + const navigate = useNavigate(); const [umbralName, setUmbralName] = useState(''); const [found, setFound] = useState(null); const [sent, setSent] = useState(false); + const acceptConnectionsEnabled = settings?.accept_connections ?? false; + const handleSearch = async () => { if (!umbralName.trim()) return; setFound(null); @@ -67,6 +73,24 @@ export default function ConnectionSearch({ open, onOpenChange }: ConnectionSearc
+ {!acceptConnectionsEnabled ? ( +
+ +

+ You need to enable Accept Connections in your settings before you can send or receive connection requests. +

+ +
+ ) : ( + <>
@@ -135,6 +159,8 @@ export default function ConnectionSearch({ open, onOpenChange }: ConnectionSearc Connection request sent
)} + + )}
diff --git a/frontend/src/components/notifications/NotificationToaster.tsx b/frontend/src/components/notifications/NotificationToaster.tsx index ea69f3e..3e02d51 100644 --- a/frontend/src/components/notifications/NotificationToaster.tsx +++ b/frontend/src/components/notifications/NotificationToaster.tsx @@ -3,14 +3,15 @@ import { toast } from 'sonner'; import { Check, X, Bell, UserPlus } from 'lucide-react'; import { useQueryClient } from '@tanstack/react-query'; import { useNotifications } from '@/hooks/useNotifications'; -import api from '@/lib/api'; +import api, { getErrorMessage } from '@/lib/api'; import type { AppNotification } from '@/types'; export default function NotificationToaster() { - const { notifications } = useNotifications(); + const { notifications, unreadCount } = useNotifications(); const queryClient = useQueryClient(); - const seenIdsRef = useRef(new Set()); + const maxSeenIdRef = useRef(0); const initializedRef = useRef(false); + const prevUnreadRef = useRef(0); const handleConnectionRespond = useCallback( async (requestId: number, action: 'accept' | 'reject', toastId: string | number) => { @@ -21,33 +22,45 @@ export default function NotificationToaster() { queryClient.invalidateQueries({ queryKey: ['connections'] }); queryClient.invalidateQueries({ queryKey: ['people'] }); queryClient.invalidateQueries({ queryKey: ['notifications'] }); - } catch { + } catch (err) { toast.dismiss(toastId); - toast.error('Failed to respond to request'); + toast.error(getErrorMessage(err, 'Failed to respond to request')); } }, [queryClient], ); + // Track unread count changes to force-refetch the list useEffect(() => { - if (!notifications.length && !initializedRef.current) return; + if (unreadCount > prevUnreadRef.current && initializedRef.current) { + queryClient.invalidateQueries({ queryKey: ['notifications', 'list'] }); + } + prevUnreadRef.current = unreadCount; + }, [unreadCount, queryClient]); - // On first load, record all existing IDs without toasting + // Show toasts for new notifications (ID > max seen) + useEffect(() => { + if (!notifications.length) return; + + // On first load, record the max ID without toasting if (!initializedRef.current) { - notifications.forEach((n) => seenIdsRef.current.add(n.id)); + maxSeenIdRef.current = Math.max(...notifications.map((n) => n.id)); initializedRef.current = true; return; } - // Find new notifications we haven't seen + // Find unread notifications with IDs higher than our watermark const newNotifications = notifications.filter( - (n) => !n.is_read && !seenIdsRef.current.has(n.id), + (n) => !n.is_read && n.id > maxSeenIdRef.current, ); - // Record all current IDs - notifications.forEach((n) => seenIdsRef.current.add(n.id)); + // Advance watermark + const maxCurrent = Math.max(...notifications.map((n) => n.id)); + if (maxCurrent > maxSeenIdRef.current) { + maxSeenIdRef.current = maxCurrent; + } - // Show toasts for new notifications + // Show toasts newNotifications.forEach((notification) => { if (notification.type === 'connection_request' && notification.source_id) { showConnectionRequestToast(notification); @@ -63,8 +76,6 @@ export default function NotificationToaster() { const showConnectionRequestToast = (notification: AppNotification) => { const requestId = notification.source_id!; - const senderName = - (notification.data as Record)?.sender_umbral_name || 'Someone'; toast.custom( (id) => ( @@ -76,7 +87,7 @@ export default function NotificationToaster() {

Connection Request

- {notification.message || `${senderName} wants to connect with you`} + {notification.message || 'Someone wants to connect with you'}