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 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-04 06:21:43 +08:00
parent 03fd8dba97
commit e27beb7736
4 changed files with 71 additions and 33 deletions

View File

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

View File

@ -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<boolean | null>(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
</DialogDescription>
</DialogHeader>
<div className="space-y-4 pt-2">
{!acceptConnectionsEnabled ? (
<div className="flex flex-col items-center gap-3 py-4 text-center">
<AlertCircle className="h-8 w-8 text-amber-400" />
<p className="text-sm text-muted-foreground">
You need to enable <span className="text-foreground font-medium">Accept Connections</span> in your settings before you can send or receive connection requests.
</p>
<Button
size="sm"
variant="outline"
className="gap-1.5"
onClick={() => { handleClose(); navigate('/settings'); }}
>
<Settings className="h-3.5 w-3.5" />
Go to Settings
</Button>
</div>
) : (
<>
<div className="space-y-2">
<Label htmlFor="umbral_search">Umbral Name</Label>
<div className="flex gap-2">
@ -135,6 +159,8 @@ export default function ConnectionSearch({ open, onOpenChange }: ConnectionSearc
Connection request sent
</div>
)}
</>
)}
</div>
</DialogContent>
</Dialog>

View File

@ -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<number>());
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<string, string>)?.sender_umbral_name || 'Someone';
toast.custom(
(id) => (
@ -76,7 +87,7 @@ export default function NotificationToaster() {
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">Connection Request</p>
<p className="text-xs text-muted-foreground mt-0.5">
{notification.message || `${senderName} wants to connect with you`}
{notification.message || 'Someone wants to connect with you'}
</p>
<div className="flex items-center gap-2 mt-3">
<button

View File

@ -6,7 +6,6 @@ import type { NotificationListResponse } from '@/types';
export function useNotifications() {
const queryClient = useQueryClient();
const visibleRef = useRef(true);
const prevUnreadRef = useRef<number | undefined>(undefined);
// Track tab visibility to pause polling when hidden
useEffect(() => {
@ -23,20 +22,10 @@ export function useNotifications() {
const { data } = await api.get<{ count: number }>('/notifications/unread-count');
return data.count;
},
refetchInterval: () => (visibleRef.current ? 30_000 : false),
staleTime: 15_000,
refetchInterval: () => (visibleRef.current ? 15_000 : false),
staleTime: 10_000,
});
// When unread count increases, immediately refetch the notification list
useEffect(() => {
const count = unreadQuery.data;
if (count === undefined) return;
if (prevUnreadRef.current !== undefined && count > prevUnreadRef.current) {
queryClient.invalidateQueries({ queryKey: ['notifications', 'list'] });
}
prevUnreadRef.current = count;
}, [unreadQuery.data, queryClient]);
const listQuery = useQuery({
queryKey: ['notifications', 'list'],
queryFn: async () => {